mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
2589 lines
108 KiB
TypeScript
2589 lines
108 KiB
TypeScript
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createBuilder } from "vite";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
import {
|
|
getPagesClientAssets,
|
|
setPagesClientAssets,
|
|
} from "../packages/vinext/src/server/pages-client-assets.js";
|
|
import { APP_FIXTURE_DIR, createIsolatedFixture, testCacheDir } from "./helpers.js";
|
|
|
|
const ROOT_LAYOUT_NOT_FOUND_REDIRECT_FIXTURE_DIR = path.resolve(
|
|
import.meta.dirname,
|
|
"./fixtures/root-layout-not-found-redirect",
|
|
);
|
|
|
|
function getStylesheetHrefs(html: string): string[] {
|
|
const hrefs: string[] = [];
|
|
const linkPattern = /<link\b[^>]*>/g;
|
|
let linkMatch: RegExpExecArray | null;
|
|
|
|
while ((linkMatch = linkPattern.exec(html)) !== null) {
|
|
const link = linkMatch[0];
|
|
if (!/\brel=["']stylesheet["']/.test(link)) continue;
|
|
|
|
const href = link.match(/\bhref=["']([^"']+)["']/)?.[1];
|
|
if (href) hrefs.push(href);
|
|
}
|
|
|
|
return hrefs;
|
|
}
|
|
|
|
async function getLinkedStylesheetText(baseUrl: string, html: string): Promise<string> {
|
|
return fetchStylesheetText(baseUrl, getStylesheetHrefs(html));
|
|
}
|
|
|
|
// RSC flight payloads reference CSS chunks as serialized stylesheet hints
|
|
// (e.g. `"href":"/assets/page-XXXX.css"`) rather than HTML `<link>` tags, so
|
|
// the `<link>` parser above does not see them. Pull every `.css` asset path out
|
|
// of the raw payload instead.
|
|
function getFlightStylesheetHrefs(payload: string): string[] {
|
|
const hrefs = new Set<string>();
|
|
const cssPattern = /["'](\/[^"']+?\.css)["']/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = cssPattern.exec(payload)) !== null) {
|
|
hrefs.add(match[1]);
|
|
}
|
|
return [...hrefs];
|
|
}
|
|
|
|
async function fetchStylesheetText(baseUrl: string, hrefs: string[]): Promise<string> {
|
|
if (hrefs.length === 0) return "";
|
|
|
|
const stylesheets = await Promise.all(
|
|
hrefs.map(async (href) => {
|
|
const res = await fetch(new URL(href, baseUrl));
|
|
expect(res.status).toBe(200);
|
|
return res.text();
|
|
}),
|
|
);
|
|
|
|
return stylesheets.join("\n");
|
|
}
|
|
|
|
function getInlineStyleText(html: string): string {
|
|
const styles: string[] = [];
|
|
const stylePattern = /<style[^>]*>([\s\S]*?)<\/style>/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = stylePattern.exec(html)) !== null) {
|
|
styles.push(match[1]);
|
|
}
|
|
return styles.join("\n");
|
|
}
|
|
|
|
async function rawHttpRequest(
|
|
url: URL,
|
|
options: { method?: string; headers?: Record<string, string> } = {},
|
|
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: Buffer }> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = http.request(url, options, (response) => {
|
|
const chunks: Buffer[] = [];
|
|
response.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
response.on("end", () => {
|
|
resolve({
|
|
status: response.statusCode ?? 0,
|
|
headers: response.headers,
|
|
body: Buffer.concat(chunks),
|
|
});
|
|
});
|
|
});
|
|
request.on("error", reject);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
async function withCountingFetchTarget<T>(
|
|
fn: (targetUrl: string, getRequestCount: () => number) => Promise<T>,
|
|
): Promise<T> {
|
|
let requestCount = 0;
|
|
const upstream = http.createServer((_req, res) => {
|
|
requestCount += 1;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ count: requestCount }));
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.once("error", reject);
|
|
upstream.listen(0, "127.0.0.1", () => {
|
|
upstream.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const address = upstream.address();
|
|
if (!address || typeof address === "string") {
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
throw new Error("Counting fetch target did not bind to a TCP port");
|
|
}
|
|
|
|
try {
|
|
return await fn(`http://127.0.0.1:${address.port}/tick`, () => requestCount);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
}
|
|
|
|
type StartedTextSequenceTarget = {
|
|
close: () => Promise<void>;
|
|
url: string;
|
|
};
|
|
|
|
async function startTextSequenceTarget(options?: {
|
|
prefix?: string;
|
|
recordRequest?: (req: http.IncomingMessage) => void;
|
|
}): Promise<StartedTextSequenceTarget> {
|
|
let responseCount = 0;
|
|
const upstream = http.createServer((req, res) => {
|
|
responseCount += 1;
|
|
options?.recordRequest?.(req);
|
|
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
res.end(`${options?.prefix ?? "random"}-${responseCount}`);
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.once("error", reject);
|
|
upstream.listen(0, "127.0.0.1", () => {
|
|
upstream.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const address = upstream.address();
|
|
if (!address || typeof address === "string") {
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
throw new Error("Text sequence target did not bind to a TCP port");
|
|
}
|
|
|
|
return {
|
|
url: `http://127.0.0.1:${address.port}/random`,
|
|
close() {
|
|
return new Promise<void>((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function startDelayedJsonSequenceTarget(delayMs: number): Promise<StartedTextSequenceTarget> {
|
|
let responseCount = 0;
|
|
const upstream = http.createServer((req, res) => {
|
|
responseCount += 1;
|
|
const current = responseCount;
|
|
setTimeout(() => {
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(
|
|
JSON.stringify({
|
|
count: current,
|
|
method: req.method,
|
|
}),
|
|
);
|
|
}, delayMs);
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.once("error", reject);
|
|
upstream.listen(0, "127.0.0.1", () => {
|
|
upstream.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const address = upstream.address();
|
|
if (!address || typeof address === "string") {
|
|
await new Promise<void>((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
throw new Error("Delayed JSON target did not bind to a TCP port");
|
|
}
|
|
|
|
return {
|
|
url: `http://127.0.0.1:${address.port}/data`,
|
|
close() {
|
|
return new Promise<void>((resolve, reject) => {
|
|
upstream.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function waitForCondition(
|
|
condition: () => boolean | Promise<boolean>,
|
|
options?: { intervalMs?: number; timeoutMs?: number },
|
|
): Promise<void> {
|
|
const intervalMs = options?.intervalMs ?? 100;
|
|
const deadline = Date.now() + (options?.timeoutMs ?? 3000);
|
|
|
|
while (!(await condition())) {
|
|
if (Date.now() >= deadline) {
|
|
throw new Error("Timed out waiting for condition");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
}
|
|
}
|
|
|
|
describe("App Router Production server (startProdServer)", () => {
|
|
const outDir = path.resolve(APP_FIXTURE_DIR, "dist");
|
|
let server: import("node:http").Server | undefined;
|
|
let baseUrl: string;
|
|
let appStaticDelayTarget: StartedTextSequenceTarget | undefined;
|
|
let appStaticDynamicTarget: StartedTextSequenceTarget | undefined;
|
|
let appStaticRevalidateTarget: StartedTextSequenceTarget | undefined;
|
|
let revalidatePathFetchTarget: StartedTextSequenceTarget | undefined;
|
|
|
|
function extractRequestId(html: string): string | undefined {
|
|
return (
|
|
html.match(
|
|
/data-testid="request-id"[^>]*>(?:<!--.*?-->)*RequestID:\s*(?:<!--.*?-->)*([a-z0-9]+)/,
|
|
)?.[1] ?? html.match(/request-id[^>]*>[^<]*?([a-z0-9]{6,})/)?.[1]
|
|
);
|
|
}
|
|
|
|
function extractRandomData(html: string): string | undefined {
|
|
return html.match(/id="random-data"[^>]*>(?:<!--.*?-->)*([^<]+)/)?.[1];
|
|
}
|
|
|
|
function extractTextById(html: string, id: string): string | undefined {
|
|
return html.match(new RegExp(`id="${id}"[^>]*>(?:<!--.*?-->)*([^<]+)`))?.[1];
|
|
}
|
|
|
|
async function fetchRandomData(pathname: string, url = baseUrl): Promise<string> {
|
|
const response = await fetch(`${url}${pathname}`);
|
|
expect(response.status).toBe(200);
|
|
const html = await response.text();
|
|
const data = extractRandomData(html);
|
|
expect(data).toBeTruthy();
|
|
return data ?? "";
|
|
}
|
|
|
|
async function expectRewrittenPathRevalidates(
|
|
pathname: "/static" | "/dynamic",
|
|
url = baseUrl,
|
|
): Promise<void> {
|
|
const initial = await fetchRandomData(pathname, url);
|
|
const refreshed = await fetchRandomData(pathname, url);
|
|
expect(refreshed).toBe(initial);
|
|
|
|
const revalidateRes = await fetch(`${url}/api/revalidate?path=${pathname}`);
|
|
expect(revalidateRes.status).toBe(200);
|
|
expect(await revalidateRes.json()).toEqual({ revalidated: true });
|
|
|
|
await waitForCondition(async () => {
|
|
const revalidated = await fetchRandomData(pathname, url);
|
|
return revalidated !== initial;
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
appStaticDelayTarget = await startDelayedJsonSequenceTarget(750);
|
|
appStaticDynamicTarget = await startTextSequenceTarget({ prefix: "dynamic" });
|
|
appStaticRevalidateTarget = await startTextSequenceTarget({ prefix: "revalidate" });
|
|
revalidatePathFetchTarget = await startTextSequenceTarget();
|
|
process.env.TEST_APP_STATIC_DELAY_TARGET = appStaticDelayTarget.url;
|
|
process.env.TEST_APP_STATIC_DYNAMIC_TARGET = appStaticDynamicTarget.url;
|
|
process.env.TEST_APP_STATIC_REVALIDATE_TARGET = appStaticRevalidateTarget.url;
|
|
process.env.TEST_REVALIDATE_PATH_REWRITES_TARGET = revalidatePathFetchTarget.url;
|
|
|
|
try {
|
|
// Build the app-basic fixture to the default dist/ directory
|
|
const builder = await createBuilder({
|
|
root: APP_FIXTURE_DIR,
|
|
cacheDir: testCacheDir(APP_FIXTURE_DIR),
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: APP_FIXTURE_DIR })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
// Start the production server on a random available port
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server } = await startProdServer({ port: 0, outDir, noCompression: false }));
|
|
const addr = server!.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 4210;
|
|
baseUrl = `http://localhost:${port}`;
|
|
} catch (error) {
|
|
server?.close();
|
|
try {
|
|
await appStaticDelayTarget?.close();
|
|
await appStaticDynamicTarget?.close();
|
|
await appStaticRevalidateTarget?.close();
|
|
await revalidatePathFetchTarget?.close();
|
|
} finally {
|
|
appStaticDelayTarget = undefined;
|
|
appStaticDynamicTarget = undefined;
|
|
appStaticRevalidateTarget = undefined;
|
|
revalidatePathFetchTarget = undefined;
|
|
delete process.env.TEST_APP_STATIC_DELAY_TARGET;
|
|
delete process.env.TEST_APP_STATIC_DYNAMIC_TARGET;
|
|
delete process.env.TEST_APP_STATIC_REVALIDATE_TARGET;
|
|
delete process.env.TEST_REVALIDATE_PATH_REWRITES_TARGET;
|
|
}
|
|
throw error;
|
|
}
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
server?.close();
|
|
await appStaticDelayTarget?.close();
|
|
await appStaticDynamicTarget?.close();
|
|
await appStaticRevalidateTarget?.close();
|
|
await revalidatePathFetchTarget?.close();
|
|
delete process.env.TEST_APP_STATIC_DELAY_TARGET;
|
|
delete process.env.TEST_APP_STATIC_DYNAMIC_TARGET;
|
|
delete process.env.TEST_APP_STATIC_REVALIDATE_TARGET;
|
|
delete process.env.TEST_REVALIDATE_PATH_REWRITES_TARGET;
|
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("serves the home page with SSR 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("Welcome to App Router");
|
|
expect(html).toContain("<script");
|
|
});
|
|
|
|
it("keeps source-page paths and cache observations out of document HTML", async () => {
|
|
const res = await fetch(`${baseUrl}/features`);
|
|
expect(res.status).toBe(200);
|
|
|
|
const html = await res.text();
|
|
expect(html).toContain("route group");
|
|
expect(html).not.toContain("__sourcePage");
|
|
expect(html).not.toContain("/(marketing)/features/page");
|
|
expect(html).not.toContain("__renderObservation");
|
|
expect(html).not.toContain("_N_T_/(marketing)");
|
|
});
|
|
|
|
it("bundles a static CommonJS request encoded with String.fromCharCode", async () => {
|
|
const res = await fetch(`${baseUrl}/char-code-require`);
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toContain("loaded from a character-code require");
|
|
});
|
|
|
|
it("serves static asset byte ranges from the identity representation", async () => {
|
|
const html = await (await fetch(`${baseUrl}/`)).text();
|
|
const href = html.match(/["'](\/_next\/static\/[^"']+\.(?:js|css))["']/)?.[1];
|
|
if (!href) throw new Error("Expected the production HTML to reference a static asset");
|
|
|
|
const assetUrl = new URL(href, baseUrl);
|
|
const full = await fetch(assetUrl);
|
|
const fullBody = new Uint8Array(await full.arrayBuffer());
|
|
expect(fullBody.byteLength).toBeGreaterThan(10);
|
|
expect(full.headers.get("accept-ranges")).toBe("bytes");
|
|
expect(full.headers.get("last-modified")).not.toBeNull();
|
|
|
|
const partial = await fetch(assetUrl, {
|
|
headers: { Range: "bytes=2-9", "Accept-Encoding": "br, gzip" },
|
|
});
|
|
expect(partial.status).toBe(206);
|
|
expect(partial.headers.get("content-range")).toBe(`bytes 2-9/${fullBody.byteLength}`);
|
|
expect(partial.headers.get("content-length")).toBe("8");
|
|
expect(partial.headers.get("content-encoding")).toBeNull();
|
|
expect(new Uint8Array(await partial.arrayBuffer())).toEqual(fullBody.subarray(2, 10));
|
|
|
|
const hugeEnd = await fetch(assetUrl, {
|
|
headers: { Range: "bytes=2-9007199254740992" },
|
|
});
|
|
expect(hugeEnd.status).toBe(206);
|
|
expect(hugeEnd.headers.get("content-range")).toBe(
|
|
`bytes 2-${fullBody.length - 1}/${fullBody.length}`,
|
|
);
|
|
expect(new Uint8Array(await hugeEnd.arrayBuffer())).toEqual(fullBody.subarray(2));
|
|
|
|
const matchingIfRange = await fetch(assetUrl, {
|
|
headers: {
|
|
Range: "bytes=2-9",
|
|
"If-Range": full.headers.get("last-modified")!,
|
|
},
|
|
});
|
|
expect(matchingIfRange.status).toBe(206);
|
|
expect(new Uint8Array(await matchingIfRange.arrayBuffer())).toEqual(fullBody.subarray(2, 10));
|
|
|
|
const futureIfRange = await fetch(assetUrl, {
|
|
headers: {
|
|
Range: "bytes=2-9",
|
|
"If-Range": "Thu, 01 Jan 2099 00:00:00 GMT",
|
|
},
|
|
});
|
|
expect(futureIfRange.status).toBe(206);
|
|
expect(futureIfRange.headers.get("content-range")).toBe(`bytes 2-9/${fullBody.byteLength}`);
|
|
expect(new Uint8Array(await futureIfRange.arrayBuffer())).toEqual(fullBody.subarray(2, 10));
|
|
|
|
const invalidIfRange = await fetch(assetUrl, {
|
|
headers: {
|
|
Range: "bytes=2-9",
|
|
"If-Range": "Sun, 31 Feb 2099 00:00:00 GMT",
|
|
},
|
|
});
|
|
expect(invalidIfRange.status).toBe(200);
|
|
expect(invalidIfRange.headers.get("content-range")).toBeNull();
|
|
expect(new Uint8Array(await invalidIfRange.arrayBuffer())).toEqual(fullBody);
|
|
|
|
const unsatisfiable = await fetch(assetUrl, {
|
|
headers: { Range: "bytes=9007199254740992-" },
|
|
});
|
|
expect(unsatisfiable.status).toBe(416);
|
|
expect(unsatisfiable.headers.get("content-range")).toBe(`bytes */${fullBody.byteLength}`);
|
|
|
|
const head = await fetch(assetUrl, {
|
|
method: "HEAD",
|
|
headers: { Range: "bytes=2-9" },
|
|
});
|
|
expect(head.status).toBe(206);
|
|
expect(head.headers.get("content-range")).toBe(`bytes 2-9/${fullBody.byteLength}`);
|
|
expect(head.headers.get("content-length")).toBe("8");
|
|
expect((await head.arrayBuffer()).byteLength).toBe(0);
|
|
});
|
|
|
|
it("evaluates static asset preconditions before byte ranges", async () => {
|
|
const html = await (await fetch(`${baseUrl}/`)).text();
|
|
const href = html.match(/["'](\/_next\/static\/[^"']+\.(?:js|css))["']/)?.[1];
|
|
if (!href) throw new Error("Expected the production HTML to reference a static asset");
|
|
|
|
const assetUrl = new URL(href, baseUrl);
|
|
const full = await rawHttpRequest(assetUrl);
|
|
const etag = full.headers.etag;
|
|
const lastModified = full.headers["last-modified"];
|
|
if (!etag || !lastModified) throw new Error("Expected static validators");
|
|
|
|
const notModified = await rawHttpRequest(assetUrl, {
|
|
headers: { "If-None-Match": etag, Range: "bytes=0-2" },
|
|
});
|
|
expect(notModified.status).toBe(304);
|
|
expect(notModified.headers["content-range"]).toBeUndefined();
|
|
expect(notModified.body).toHaveLength(0);
|
|
|
|
const failed = await rawHttpRequest(assetUrl, {
|
|
headers: { "If-Match": '"different"', Range: "bytes=0-2" },
|
|
});
|
|
expect(failed.status).toBe(412);
|
|
expect(failed.headers["content-range"]).toBeUndefined();
|
|
expect(failed.body).toHaveLength(0);
|
|
|
|
expect(etag).toMatch(/^W\//);
|
|
const matchingIfMatch = await rawHttpRequest(assetUrl, {
|
|
headers: { "If-Match": etag },
|
|
});
|
|
expect(matchingIfMatch.status).toBe(200);
|
|
expect(matchingIfMatch.body).toEqual(full.body);
|
|
|
|
const range = await rawHttpRequest(assetUrl, {
|
|
headers: {
|
|
"If-Match": "*",
|
|
"If-Unmodified-Since": "Thu, 01 Jan 1970 00:00:00 GMT",
|
|
Range: "bytes=0-2",
|
|
},
|
|
});
|
|
expect(range.status).toBe(206);
|
|
expect(range.body).toEqual(full.body.subarray(0, 3));
|
|
|
|
const head = await rawHttpRequest(assetUrl, {
|
|
method: "HEAD",
|
|
headers: { "If-Modified-Since": lastModified },
|
|
});
|
|
expect(head.status).toBe(304);
|
|
expect(head.body).toHaveLength(0);
|
|
|
|
const unsafe = await rawHttpRequest(assetUrl, { method: "POST" });
|
|
expect(unsafe.status).toBe(405);
|
|
expect(unsafe.headers.allow).toBe("GET, HEAD");
|
|
|
|
const unsafeConditional = await rawHttpRequest(assetUrl, {
|
|
method: "POST",
|
|
headers: { "If-None-Match": etag },
|
|
});
|
|
expect(unsafeConditional.status).toBe(405);
|
|
expect(unsafeConditional.headers.allow).toBe("GET, HEAD");
|
|
|
|
const forcedRange = await rawHttpRequest(assetUrl, {
|
|
headers: {
|
|
"Cache-Control": "no-cache",
|
|
"If-None-Match": etag,
|
|
Range: "bytes=0-2",
|
|
},
|
|
});
|
|
expect(forcedRange.status).toBe(206);
|
|
expect(forcedRange.body).toEqual(full.body.subarray(0, 3));
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-static/app-static.test.ts
|
|
it("contains dynamic = 'error' failures without terminating later App Router requests", async () => {
|
|
const errorRes = await fetch(
|
|
`${baseUrl}/nextjs-compat/app-static-dynamic-error/static-bailout-1`,
|
|
);
|
|
expect(errorRes.status).toBe(500);
|
|
|
|
const forceStaticRes = await fetch(
|
|
`${baseUrl}/nextjs-compat/app-static-force-static/static-bailout-1`,
|
|
{
|
|
headers: {
|
|
cookie: "app-static-dynamic=hidden",
|
|
"x-app-static": "hidden",
|
|
},
|
|
},
|
|
);
|
|
expect(forceStaticRes.status).toBe(200);
|
|
const forceStaticHtml = await forceStaticRes.text();
|
|
expect(forceStaticHtml).toContain("/nextjs-compat/app-static-force-static");
|
|
expect(forceStaticHtml).toContain("static-bailout-1");
|
|
expect(forceStaticHtml).toContain('<p id="headers">[]</p>');
|
|
expect(forceStaticHtml).toContain('<p id="cookies">[]</p>');
|
|
|
|
const homeRes = await fetch(`${baseUrl}/`);
|
|
expect(homeRes.status).toBe(200);
|
|
expect(await homeRes.text()).toContain("Welcome to App Router");
|
|
});
|
|
|
|
// Next.js v16.2.6 only selects NOT_FOUND fallback when dynamicParams is explicitly false:
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/build/static-paths/app.ts
|
|
it("matches dynamic = 'error' dynamicParams admission semantics", async () => {
|
|
for (const route of ["dynamic-error", "dynamic-error-true"]) {
|
|
const unknown = await fetch(`${baseUrl}/layout-segment-config/${route}/unknown`);
|
|
expect(unknown.status).toBe(200);
|
|
}
|
|
|
|
const known = await fetch(`${baseUrl}/layout-segment-config/dynamic-error-false/known`);
|
|
expect(known.status).toBe(200);
|
|
|
|
const unknown = await fetch(`${baseUrl}/layout-segment-config/dynamic-error-false/unknown`);
|
|
expect(unknown.status).toBe(404);
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts
|
|
it("revalidates static App pages that combine route config and fetch revalidate", async () => {
|
|
const initialRes = await fetch(`${baseUrl}/nextjs-compat/app-static-config-fetch-revalidate`);
|
|
expect(initialRes.status).toBe(200);
|
|
const initialHtml = await initialRes.text();
|
|
const initialDate = extractTextById(initialHtml, "date");
|
|
const initialData = extractTextById(initialHtml, "random-data");
|
|
expect(initialDate).toBeTruthy();
|
|
expect(initialData).toBeTruthy();
|
|
|
|
let firstFreshDate = "";
|
|
let firstFreshData = "";
|
|
await waitForCondition(
|
|
async () => {
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/app-static-config-fetch-revalidate`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
firstFreshDate = extractTextById(html, "date") ?? "";
|
|
firstFreshData = extractTextById(html, "random-data") ?? "";
|
|
return firstFreshDate !== initialDate && firstFreshData !== initialData;
|
|
},
|
|
{ timeoutMs: 5000 },
|
|
);
|
|
|
|
await waitForCondition(
|
|
async () => {
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/app-static-config-fetch-revalidate`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
const nextDate = extractTextById(html, "date");
|
|
const nextData = extractTextById(html, "random-data");
|
|
return nextDate !== firstFreshDate && nextData !== firstFreshData;
|
|
},
|
|
{ timeoutMs: 5000 },
|
|
);
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts
|
|
it("does not cache generated static params when the page uses dynamic fetch data", async () => {
|
|
const pathname = "/nextjs-compat/app-static-gen-params-dynamic/one";
|
|
const initialRes = await fetch(`${baseUrl}${pathname}`);
|
|
expect(initialRes.status).toBe(200);
|
|
const initialHtml = await initialRes.text();
|
|
expect(initialHtml).toContain("/nextjs-compat/app-static-gen-params-dynamic/[slug]");
|
|
expect(extractTextById(initialHtml, "slug")).toBe("one");
|
|
const initialData = extractTextById(initialHtml, "data");
|
|
expect(initialData).toBeTruthy();
|
|
|
|
for (let index = 0; index < 3; index++) {
|
|
const res = await fetch(`${baseUrl}${pathname}`);
|
|
expect(res.status).toBe(200);
|
|
const data = extractTextById(await res.text(), "data");
|
|
expect(data).toBeTruthy();
|
|
expect(data).not.toBe(initialData);
|
|
}
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts
|
|
it("caches lazy dynamic params for force-static App pages", async () => {
|
|
const pathname = "/nextjs-compat/app-static-force-static/lazy";
|
|
const first = await fetch(`${baseUrl}${pathname}`, {
|
|
headers: {
|
|
cookie: "app-static-dynamic=hidden",
|
|
"x-app-static": "hidden",
|
|
},
|
|
});
|
|
expect(first.status).toBe(200);
|
|
const firstHtml = await first.text();
|
|
expect(extractTextById(firstHtml, "id")).toBe("lazy");
|
|
expect(firstHtml).toContain('<p id="headers">[]</p>');
|
|
expect(firstHtml).toContain('<p id="cookies">[]</p>');
|
|
const firstNow = extractTextById(firstHtml, "now");
|
|
expect(firstNow).toBeTruthy();
|
|
|
|
const second = await fetch(`${baseUrl}${pathname}`);
|
|
expect(second.status).toBe(200);
|
|
expect(extractTextById(await second.text(), "now")).toBe(firstNow);
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts
|
|
it("serves stale edge App page responses without waiting for regeneration", async () => {
|
|
const pathname = "/nextjs-compat/app-static-stale-cache-serving-edge/app-page";
|
|
const prime = await fetch(`${baseUrl}${pathname}`);
|
|
expect(prime.status).toBe(200);
|
|
await prime.text();
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
const started = Date.now();
|
|
const stale = await fetch(`${baseUrl}${pathname}`);
|
|
const html = await stale.text();
|
|
const duration = Date.now() - started;
|
|
expect(stale.status).toBe(200);
|
|
expect(duration).toBeLessThan(500);
|
|
const data = JSON.parse((extractTextById(html, "data") ?? "{}").replace(/"/g, '"'));
|
|
expect(data.data.count).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts
|
|
//
|
|
// Next.js route-level global CSS is scoped to the matched App Router route's
|
|
// CSS chunks. A sibling route that imports `scroll-padding-top: 20px` must not
|
|
// contribute CSS to this page; otherwise native hash scrolling lands exactly
|
|
// 20px short, which is what the upstream navigation deploy-suite observed.
|
|
it("keeps production route-level global CSS isolated between sibling hash routes", async () => {
|
|
const noOffsetRes = await fetch(`${baseUrl}/nextjs-compat/hash-scroll-css-isolation`);
|
|
expect(noOffsetRes.status).toBe(200);
|
|
const noOffsetHtml = await noOffsetRes.text();
|
|
const noOffsetCss = await getLinkedStylesheetText(baseUrl, noOffsetHtml);
|
|
expect(noOffsetCss).not.toContain("scroll-padding-top");
|
|
|
|
const offsetRes = await fetch(`${baseUrl}/nextjs-compat/hash-scroll-css-isolation-with-offset`);
|
|
expect(offsetRes.status).toBe(200);
|
|
const offsetHtml = await offsetRes.text();
|
|
const offsetCss = await getLinkedStylesheetText(baseUrl, offsetHtml);
|
|
expect(offsetCss).toContain("scroll-padding-top:20px");
|
|
});
|
|
|
|
it("keeps production route-level global CSS isolated for intercepted modal pages", async () => {
|
|
// Direct feed visit: the modal page module is lazy, so its CSS must
|
|
// not be emitted when the intercept is not active.
|
|
const feedRes = await fetch(`${baseUrl}/feed`);
|
|
expect(feedRes.status).toBe(200);
|
|
const feedHtml = await feedRes.text();
|
|
const feedLinkedCss = await getLinkedStylesheetText(baseUrl, feedHtml);
|
|
expect(feedLinkedCss).not.toContain("scroll-padding-top");
|
|
const feedInlineCss = getInlineStyleText(feedHtml);
|
|
expect(feedInlineCss).not.toContain("scroll-padding-top");
|
|
});
|
|
|
|
it("emits intercepted modal CSS on RSC navigation from feed", async () => {
|
|
// Positive direction for the lazy intercept-page load: navigating from
|
|
// /feed to /photos/[id] fires the intercept, so the modal page module — and
|
|
// therefore its `scroll-padding-top` CSS chunk — must be loaded and
|
|
// referenced in the flight payload. Without this guard, a regression where
|
|
// the lazy modal page fails to load its CSS would pass the negative test
|
|
// above silently. Mirrors the dev-server intercept request shape.
|
|
const res = await fetch(`${baseUrl}/photos/43.rsc`, {
|
|
headers: {
|
|
Accept: "text/x-component",
|
|
"X-Vinext-Interception-Context": "/feed",
|
|
},
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toContain("text/x-component");
|
|
|
|
const payload = await res.text();
|
|
// Confirm the intercept actually fired before asserting on its CSS.
|
|
expect(payload).toContain("photo-modal");
|
|
|
|
const interceptCss = await fetchStylesheetText(baseUrl, getFlightStylesheetHrefs(payload));
|
|
expect(interceptCss).toContain("scroll-padding-top:20px");
|
|
});
|
|
|
|
it("does not reuse cached HTML across requests with different CSP nonces", async () => {
|
|
const firstRes = await fetch(`${baseUrl}/revalidate-test?csp-nonce=first`);
|
|
expect(firstRes.status).toBe(200);
|
|
expect(firstRes.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(firstRes.headers.get("content-security-policy")).toBe(
|
|
"script-src 'nonce-first' 'strict-dynamic';",
|
|
);
|
|
const firstHtml = await firstRes.text();
|
|
expect(firstHtml).toContain(
|
|
'<script nonce="first">Object.assign(((self[Symbol.for("vinext.navigationRuntime")]',
|
|
);
|
|
|
|
const secondRes = await fetch(`${baseUrl}/revalidate-test?csp-nonce=second`);
|
|
expect(secondRes.status).toBe(200);
|
|
expect(secondRes.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(secondRes.headers.get("content-security-policy")).toBe(
|
|
"script-src 'nonce-second' 'strict-dynamic';",
|
|
);
|
|
const secondHtml = await secondRes.text();
|
|
expect(secondHtml).toContain(
|
|
'<script nonce="second">Object.assign(((self[Symbol.for("vinext.navigationRuntime")]',
|
|
);
|
|
expect(secondHtml).not.toContain('nonce="first"');
|
|
});
|
|
|
|
it("preloads rendered next/dynamic chunks with the CSP nonce", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/next-dynamic-csp-nonce/next-dynamic-csp-nonce.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-dynamic-csp-nonce/next-dynamic-csp-nonce.test.ts
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/dynamic?csp-nonce=1`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-security-policy")).toBe(
|
|
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
|
|
);
|
|
|
|
const html = await res.text();
|
|
const dynamicScriptPreloads =
|
|
html.match(
|
|
/<link\b(?=[^>]*\brel="modulepreload")(?=[^>]*\bas="script")(?=[^>]*\bfetchpriority="low")(?=[^>]*\bhref="[^"]*\/_next\/static\/chunks\/[^"]*\.js")[^>]*>/gi,
|
|
) ?? [];
|
|
|
|
expect(dynamicScriptPreloads.length).toBeGreaterThan(0);
|
|
for (const tag of dynamicScriptPreloads) {
|
|
expect(tag).toContain('nonce="vinext-test-nonce"');
|
|
}
|
|
});
|
|
|
|
// Edge case: the next/dynamic() CALL SITE is a Server Component (no
|
|
// "use client") that lazy-loads a client component. The sibling
|
|
// /nextjs-compat/dynamic page only calls dynamic() from "use client" modules,
|
|
// which render in the SSR pass where the script-nonce context is set — so it
|
|
// does not cover this path.
|
|
//
|
|
// Next.js parity: <PreloadChunks> is a 'use client' component, so it renders
|
|
// in the SSR pass and the preload links carry the nonce regardless of whether
|
|
// the dynamic() call site is a Server or Client Component. vinext matches
|
|
// this: DynamicPreloadChunks is ALSO a 'use client' component (see
|
|
// shims/dynamic-preload-chunks.tsx), so it renders in the SSR pass — where
|
|
// withScriptNonce installs the provider — and the nonce is applied even for a
|
|
// Server-Component call site. This test guards exactly that. (Before that fix
|
|
// it was rendered in the RSC environment, where useScriptNonce() returns
|
|
// undefined and the nonce was dropped.)
|
|
it("preloads next/dynamic chunks with the CSP nonce when the call site is a Server Component", async () => {
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/dynamic/rsc-imports-client?csp-nonce=1`);
|
|
expect(res.status).toBe(200);
|
|
// Sanity: middleware ran and applied the nonce-based CSP for this route.
|
|
expect(res.headers.get("content-security-policy")).toBe(
|
|
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
|
|
);
|
|
|
|
const html = await res.text();
|
|
// Sanity: the dynamically-imported client widget actually rendered, so a
|
|
// client chunk exists and a preload link is expected.
|
|
expect(html).toContain("rsc-imports-client-widget");
|
|
|
|
const dynamicScriptPreloads =
|
|
html.match(
|
|
/<link\b(?=[^>]*\brel="modulepreload")(?=[^>]*\bas="script")(?=[^>]*\bfetchpriority="low")(?=[^>]*\bhref="[^"]*\/_next\/static\/chunks\/[^"]*\.js")[^>]*>/gi,
|
|
) ?? [];
|
|
|
|
// Parity expectation: a Server-Component call site must still emit a
|
|
// nonce-bearing preload for the dynamically-loaded client chunk.
|
|
expect(dynamicScriptPreloads.length).toBeGreaterThan(0);
|
|
for (const tag of dynamicScriptPreloads) {
|
|
expect(tag).toContain('nonce="vinext-test-nonce"');
|
|
}
|
|
});
|
|
|
|
it("preloads next/dynamic CSS with the CSP nonce from a Server Component call site", async () => {
|
|
// The dynamically-loaded client widget imports CSS. Next.js's <PreloadChunks>
|
|
// preloads dynamic CSS server-side "to avoid flash of unstyled content", so
|
|
// the stylesheet link must be emitted with the request nonce. React Float
|
|
// renders the `precedence` prop as the `data-precedence` attribute.
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/dynamic/rsc-imports-client?csp-nonce=1`);
|
|
expect(res.status).toBe(200);
|
|
|
|
const html = await res.text();
|
|
const dynamicStylesheets = (html.match(/<link\b[^>]*>/g) ?? []).filter(
|
|
(tag) => /\brel="stylesheet"/.test(tag) && /\bdata-precedence="dynamic"/.test(tag),
|
|
);
|
|
|
|
expect(dynamicStylesheets.length).toBeGreaterThan(0);
|
|
for (const tag of dynamicStylesheets) {
|
|
expect(tag).toContain('nonce="vinext-test-nonce"');
|
|
// The PR deliberately drops `as="style"` — `as` is only valid on
|
|
// rel="preload" per the HTML spec, not on rel="stylesheet".
|
|
expect(tag).not.toContain('as="style"');
|
|
}
|
|
});
|
|
|
|
it("emits next/dynamic chunk preloads without a nonce when no CSP is set", async () => {
|
|
// No ?csp-nonce → middleware applies no CSP header, so no nonce is threaded.
|
|
// The preload optimization is independent of CSP: the links must still be
|
|
// emitted, but must not carry a stray/empty nonce attribute.
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/dynamic`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-security-policy")).toBeNull();
|
|
|
|
const html = await res.text();
|
|
const dynamicScriptPreloads =
|
|
html.match(
|
|
/<link\b(?=[^>]*\brel="modulepreload")(?=[^>]*\bas="script")(?=[^>]*\bfetchpriority="low")(?=[^>]*\bhref="[^"]*\/_next\/static\/chunks\/[^"]*\.js")[^>]*>/gi,
|
|
) ?? [];
|
|
|
|
expect(dynamicScriptPreloads.length).toBeGreaterThan(0);
|
|
for (const tag of dynamicScriptPreloads) {
|
|
expect(tag).not.toContain("nonce=");
|
|
}
|
|
});
|
|
|
|
it("does not emit a server preload for an ssr:false next/dynamic boundary", async () => {
|
|
// Next.js parity (lazy-dynamic/loadable.tsx): <PreloadChunks> renders only on
|
|
// the ssr:true path; an ssr:false boundary bails out to CSR and emits no
|
|
// server-side preload.
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/dynamic/ssr-false-only?csp-nonce=1`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-security-policy")).toBe(
|
|
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
|
|
);
|
|
|
|
const html = await res.text();
|
|
// Positive sanity: the page actually rendered (otherwise the negative
|
|
// assertion below could pass for the wrong reason — a blank/errored page).
|
|
// Assert the visible text, not just the id, to also catch a structural-but-
|
|
// empty render.
|
|
expect(html).toContain("This is static content");
|
|
|
|
// The DynamicPreloadChunks signature is rel="modulepreload" as="script"
|
|
// fetchPriority="low" — route bootstrap uses modulepreload instead, so this
|
|
// matches only dynamic-boundary preloads. Match the attribute name
|
|
// case-INSENSITIVELY: React currently serializes the `fetchPriority` prop
|
|
// verbatim (camelCase), but if it ever lowercased it to `fetchpriority` a
|
|
// case-sensitive matcher would match nothing and this negative assertion
|
|
// would pass vacuously even if a preload leaked.
|
|
const dynamicScriptPreloads = (html.match(/<link\b[^>]*>/g) ?? []).filter(
|
|
(tag) =>
|
|
/\brel="modulepreload"/i.test(tag) &&
|
|
/\bas="script"/i.test(tag) &&
|
|
/\bfetchpriority="low"/i.test(tag),
|
|
);
|
|
expect(dynamicScriptPreloads).toEqual([]);
|
|
});
|
|
|
|
it("preloads rendered next/dynamic chunks with assetPrefix and the CSP nonce", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-dynamic-asset-prefix-"));
|
|
const fixtureRoot = path.join(tmpDir, "fixture");
|
|
const prefixedOutDir = path.join(fixtureRoot, "dist");
|
|
let assetPrefixServer: import("node:http").Server | undefined;
|
|
const previousPagesClientAssets = getPagesClientAssets();
|
|
const prodGlobalKeys = [
|
|
"__vite_rsc_client_require__",
|
|
"__vite_rsc_require__",
|
|
"__vite_rsc_server_require__",
|
|
"__webpack_chunk_load__",
|
|
"__webpack_require__",
|
|
];
|
|
// startProdServer installs build/runtime globals process-wide. This test
|
|
// starts a second prod server while the shared server is still alive, so
|
|
// restore the shared server's globals before later tests run.
|
|
const previousGlobals = new Map(
|
|
prodGlobalKeys.map((key) => [
|
|
key,
|
|
{
|
|
exists: Reflect.has(globalThis, key),
|
|
value: Reflect.get(globalThis, key),
|
|
},
|
|
]),
|
|
);
|
|
|
|
try {
|
|
fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true });
|
|
fs.rmSync(prefixedOutDir, { recursive: true, force: true });
|
|
const fixtureNodeModules = path.join(fixtureRoot, "node_modules");
|
|
if (!fs.existsSync(fixtureNodeModules)) {
|
|
fs.symlinkSync(
|
|
path.resolve(__dirname, "..", "node_modules"),
|
|
fixtureNodeModules,
|
|
"junction",
|
|
);
|
|
}
|
|
|
|
const nextConfigPath = path.join(fixtureRoot, "next.config.ts");
|
|
const nextConfig = fs.readFileSync(nextConfigPath, "utf-8");
|
|
fs.writeFileSync(
|
|
nextConfigPath,
|
|
nextConfig.replace(
|
|
"const nextConfig: NextConfig = {",
|
|
'const nextConfig: NextConfig = {\n assetPrefix: "/cdn",',
|
|
),
|
|
);
|
|
|
|
const builder = await createBuilder({
|
|
root: fixtureRoot,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: fixtureRoot })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server: assetPrefixServer } = await startProdServer({
|
|
port: 0,
|
|
outDir: prefixedOutDir,
|
|
noCompression: true,
|
|
}));
|
|
const addr = assetPrefixServer.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
const tmpBaseUrl = `http://localhost:${port}`;
|
|
|
|
const res = await fetch(`${tmpBaseUrl}/nextjs-compat/dynamic?csp-nonce=1`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-security-policy")).toBe(
|
|
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
|
|
);
|
|
|
|
const html = await res.text();
|
|
// DynamicPreloadChunks marks its modulepreload with fetchPriority="low";
|
|
// use that signal to avoid matching route bootstrap modulepreload links.
|
|
const dynamicScriptPreloads = (html.match(/<link\b[^>]*>/g) ?? []).filter(
|
|
(tag) =>
|
|
/\bfetchpriority="low"/i.test(tag) &&
|
|
tag.includes('nonce="vinext-test-nonce"') &&
|
|
tag.includes("/_next/static/chunks/"),
|
|
);
|
|
|
|
expect(dynamicScriptPreloads.length).toBeGreaterThan(0);
|
|
for (const tag of dynamicScriptPreloads) {
|
|
expect(tag).toMatch(/\bhref="\/cdn\/_next\/static\/chunks\/[^"]+\.js"/);
|
|
}
|
|
} finally {
|
|
assetPrefixServer?.close();
|
|
setPagesClientAssets(previousPagesClientAssets);
|
|
for (const [key, previous] of previousGlobals) {
|
|
if (previous.exists) {
|
|
Reflect.set(globalThis, key, previous.value);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, key);
|
|
}
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
|
|
it("preloads rendered next/dynamic chunks with absolute assetPrefix and the CSP nonce", async () => {
|
|
const tmpDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "vinext-app-dynamic-absolute-asset-prefix-"),
|
|
);
|
|
const fixtureRoot = path.join(tmpDir, "fixture");
|
|
const prefixedOutDir = path.join(fixtureRoot, "dist");
|
|
let assetPrefixServer: import("node:http").Server | undefined;
|
|
const previousPagesClientAssets = getPagesClientAssets();
|
|
const prodGlobalKeys = [
|
|
"__vite_rsc_client_require__",
|
|
"__vite_rsc_require__",
|
|
"__vite_rsc_server_require__",
|
|
"__webpack_chunk_load__",
|
|
"__webpack_require__",
|
|
];
|
|
const previousGlobals = new Map(
|
|
prodGlobalKeys.map((key) => [
|
|
key,
|
|
{
|
|
exists: Reflect.has(globalThis, key),
|
|
value: Reflect.get(globalThis, key),
|
|
},
|
|
]),
|
|
);
|
|
|
|
try {
|
|
fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true });
|
|
fs.rmSync(prefixedOutDir, { recursive: true, force: true });
|
|
const fixtureNodeModules = path.join(fixtureRoot, "node_modules");
|
|
if (!fs.existsSync(fixtureNodeModules)) {
|
|
fs.symlinkSync(
|
|
path.resolve(__dirname, "..", "node_modules"),
|
|
fixtureNodeModules,
|
|
"junction",
|
|
);
|
|
}
|
|
|
|
const nextConfigPath = path.join(fixtureRoot, "next.config.ts");
|
|
const nextConfig = fs.readFileSync(nextConfigPath, "utf-8");
|
|
fs.writeFileSync(
|
|
nextConfigPath,
|
|
nextConfig.replace(
|
|
"const nextConfig: NextConfig = {",
|
|
'const nextConfig: NextConfig = {\n assetPrefix: "https://cdn.example.com",',
|
|
),
|
|
);
|
|
|
|
const builder = await createBuilder({
|
|
root: fixtureRoot,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: fixtureRoot })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server: assetPrefixServer } = await startProdServer({
|
|
port: 0,
|
|
outDir: prefixedOutDir,
|
|
noCompression: true,
|
|
}));
|
|
const addr = assetPrefixServer.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
const tmpBaseUrl = `http://localhost:${port}`;
|
|
|
|
const res = await fetch(`${tmpBaseUrl}/nextjs-compat/dynamic?csp-nonce=1`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-security-policy")).toBe(
|
|
"script-src 'nonce-vinext-test-nonce' 'strict-dynamic';",
|
|
);
|
|
|
|
const html = await res.text();
|
|
const dynamicScriptPreloads = (html.match(/<link\b[^>]*>/g) ?? []).filter(
|
|
(tag) =>
|
|
/\bfetchpriority="low"/i.test(tag) &&
|
|
tag.includes('nonce="vinext-test-nonce"') &&
|
|
tag.includes("https://cdn.example.com/_next/static/chunks/"),
|
|
);
|
|
|
|
expect(dynamicScriptPreloads.length).toBeGreaterThan(0);
|
|
for (const tag of dynamicScriptPreloads) {
|
|
expect(tag).toMatch(
|
|
/\bhref="https:\/\/cdn\.example\.com\/_next\/static\/chunks\/[^"]+\.js"/,
|
|
);
|
|
}
|
|
} finally {
|
|
assetPrefixServer?.close();
|
|
setPagesClientAssets(previousPagesClientAssets);
|
|
for (const [key, previous] of previousGlobals) {
|
|
if (previous.exists) {
|
|
Reflect.set(globalThis, key, previous.value);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, key);
|
|
}
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
|
|
it("does not collapse encoded slashes onto nested routes in production", async () => {
|
|
const encodedRes = await fetch(`${baseUrl}/headers%2Foverride-from-middleware`);
|
|
expect(encodedRes.status).toBe(404);
|
|
expect(encodedRes.headers.get("e2e-headers")).not.toBe("middleware");
|
|
|
|
const nestedRes = await fetch(`${baseUrl}/headers/override-from-middleware`);
|
|
expect(nestedRes.status).toBe(200);
|
|
expect(nestedRes.headers.get("e2e-headers")).toBe("middleware");
|
|
});
|
|
|
|
// Regression test for issue 1487 — App Router page-segment `revalidate`
|
|
// should produce a stable cached response. Two requests inside the
|
|
// revalidate window must return identical HTML bytes (same Date.now()
|
|
// embedded), not re-render on every request. /revalidate-test exports
|
|
// `revalidate = 60` and renders Date.now() into the HTML.
|
|
it("export const revalidate: second request inside the cache window is a HIT with identical HTML", async () => {
|
|
const res1 = await fetch(`${baseUrl}/revalidate-test`);
|
|
expect(res1.status).toBe(200);
|
|
const html1 = await res1.text();
|
|
const ts1 = html1.match(/data-testid="timestamp">(?:<!--[^>]*-->)*\s*(\d+)\s*</)?.[1];
|
|
expect(ts1).toBeTruthy();
|
|
|
|
const res2 = await fetch(`${baseUrl}/revalidate-test`);
|
|
expect(res2.status).toBe(200);
|
|
const html2 = await res2.text();
|
|
const ts2 = html2.match(/data-testid="timestamp">(?:<!--[^>]*-->)*\s*(\d+)\s*</)?.[1];
|
|
|
|
// The HIT response must return the same timestamp baked into the HTML on
|
|
// the MISS render. If vinext re-renders on every request, ts2 will be a
|
|
// fresher Date.now() than ts1.
|
|
expect(res2.headers.get("x-vinext-cache")).toBe("HIT");
|
|
expect(ts2).toBe(ts1);
|
|
});
|
|
|
|
// Regression test for issue 1487 — App Router page-segment `revalidate = Infinity`
|
|
// (and `revalidate = false`) should produce a stable cached response. Two
|
|
// requests must return identical HTML bytes; the first MISS render writes
|
|
// to the cache and the second is a HIT. This was historically broken
|
|
// because `resolveAppPageCacheControl` rejected non-finite revalidate
|
|
// intervals, so indefinite-cache pages re-rendered on every request.
|
|
it("export const revalidate = Infinity: second request is a HIT with identical HTML", async () => {
|
|
const res1 = await fetch(`${baseUrl}/revalidate-infinity-test`);
|
|
expect(res1.status).toBe(200);
|
|
const html1 = await res1.text();
|
|
const ts1 = html1.match(/data-testid="timestamp">(?:<!--[^>]*-->)*\s*(\d+)\s*</)?.[1];
|
|
expect(ts1).toBeTruthy();
|
|
|
|
const res2 = await fetch(`${baseUrl}/revalidate-infinity-test`);
|
|
expect(res2.status).toBe(200);
|
|
const html2 = await res2.text();
|
|
const ts2 = html2.match(/data-testid="timestamp">(?:<!--[^>]*-->)*\s*(\d+)\s*</)?.[1];
|
|
|
|
expect(res2.headers.get("x-vinext-cache")).toBe("HIT");
|
|
expect(ts2).toBe(ts1);
|
|
});
|
|
|
|
it("applies middleware request header overrides before App->Pages fallback rendering in production", async () => {
|
|
const res = await fetch(`${baseUrl}/pages-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("Pages Header Override Delete");
|
|
expect(html).toContain('<p id="authorization"></p>');
|
|
expect(html).toContain('<p id="cookie"></p>');
|
|
expect(html).toContain('id="middleware-header">hello-from-middleware<');
|
|
expect(html).toContain('"authorization":null');
|
|
expect(html).toContain('"cookie":null');
|
|
});
|
|
|
|
it("lets a concrete Pages data route win a middleware rewrite over an App catch-all", async () => {
|
|
// Next.js merges Pages and App matchers before applying dynamic-route
|
|
// precedence, so this concrete Pages route wins over app/docs/[...slug].
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/route-matcher-managers/default-route-matcher-manager.ts
|
|
const buildId = fs.readFileSync(path.join(outDir, "server", "BUILD_ID"), "utf8").trim();
|
|
const res = await fetch(`${baseUrl}/_next/data/${buildId}/pages-data-rewrite-source.json`, {
|
|
headers: { "x-nextjs-data": "1" },
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-nextjs-rewrite")).toBe("/docs/pages-data-rewrite-target");
|
|
expect(res.headers.get("cache-control")).toContain("private");
|
|
expect(res.headers.getSetCookie()).toEqual([
|
|
"pages-rewrite-session=middleware; Path=/",
|
|
"pages-rewrite-session=gssp; Path=/",
|
|
]);
|
|
expect(await res.json()).toEqual({
|
|
pageProps: { message: "concrete Pages GSSP" },
|
|
__N_SSP: true,
|
|
});
|
|
});
|
|
|
|
it("serves Pages Router edge API ImageResponse routes in hybrid production", async () => {
|
|
// Ported from Next.js: test/e2e/og-api/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/og-api/index.test.ts
|
|
const res = await fetch(`${baseUrl}/api/pages-og`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toContain("image/png");
|
|
expect(res.headers.get("x-mw-ran")).toBe("true");
|
|
expect((await res.blob()).size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("serves dynamic routes", async () => {
|
|
const res = await fetch(`${baseUrl}/blog/test-post`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
expect(html).toContain("test-post");
|
|
});
|
|
|
|
it("serves nested layouts", async () => {
|
|
const res = await fetch(`${baseUrl}/dashboard`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
expect(html).toContain("dashboard-layout");
|
|
});
|
|
|
|
it("returns RSC stream for .rsc requests", async () => {
|
|
const res = await fetch(`${baseUrl}/about.rsc`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toContain("text/x-component");
|
|
});
|
|
|
|
it("redirects header-only RSC requests at canonical page URLs to cache-separated Flight URLs", async () => {
|
|
const res = await fetch(`${baseUrl}/about`, {
|
|
headers: { Accept: "text/x-component", RSC: "1" },
|
|
redirect: "manual",
|
|
});
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.get("location")).toBe("/about?_rsc");
|
|
|
|
const followedRes = await fetch(`${baseUrl}${res.headers.get("location")}`, {
|
|
headers: { Accept: "text/x-component", RSC: "1" },
|
|
});
|
|
expect(followedRes.status).toBe(200);
|
|
expect(followedRes.headers.get("content-type")).toContain("text/x-component");
|
|
});
|
|
|
|
it("serves route handlers (GET /api/hello)", async () => {
|
|
const res = await fetch(`${baseUrl}/api/hello`);
|
|
expect(res.status).toBe(200);
|
|
const json = await res.json();
|
|
expect(json).toHaveProperty("message");
|
|
});
|
|
|
|
it("does not treat a route handler's reflected static-file header as a framework signal", async () => {
|
|
const direct = await fetch(`${baseUrl}/internal-header-secret.txt`);
|
|
expect(direct.status).toBe(403);
|
|
await expect(direct.text()).resolves.toBe("blocked by middleware");
|
|
|
|
const reflected = await fetch(`${baseUrl}/api/reflect-static-file-header`, {
|
|
headers: { "x-vinext-static-file": "/internal-header-secret.txt" },
|
|
});
|
|
expect(reflected.status).toBe(200);
|
|
expect(reflected.headers.get("x-vinext-static-file")).toBe("/internal-header-secret.txt");
|
|
expect(reflected.headers.get("vary")).toContain("RSC");
|
|
await expect(reflected.text()).resolves.toBe("route handler body");
|
|
|
|
const collision = await fetch(`${baseUrl}/static-file-header-collision.txt`);
|
|
expect(collision.status).toBe(200);
|
|
expect(collision.headers.get("x-vinext-static-file")).toBe("/internal-header-secret.txt");
|
|
await expect(collision.text()).resolves.toBe("STATIC_FILE_HEADER_COLLISION_SAFE\n");
|
|
});
|
|
|
|
it("preserves config Link headers alongside React preload links", async () => {
|
|
const res = await fetch(`${baseUrl}/config-link-preload`);
|
|
const link = res.headers.get("link") ?? "";
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(link).toContain('</llms.txt>; rel="describedby"; type="text/plain"');
|
|
expect(link).not.toContain("</superseded>");
|
|
expect(link).toContain("</agent-test.woff2>");
|
|
expect(link).toContain("rel=preload");
|
|
});
|
|
|
|
it("keeps middleware Link precedence while preserving React preload links", async () => {
|
|
const res = await fetch(`${baseUrl}/config-link-preload?middleware-link=1`);
|
|
const link = res.headers.get("link") ?? "";
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(link).toContain('</middleware.css>; rel="preload"; as="style"');
|
|
expect(link).toContain("</agent-test.woff2>");
|
|
expect(link).not.toContain("</llms.txt>");
|
|
expect(link).not.toContain("</superseded>");
|
|
});
|
|
|
|
it("routes unmatched API paths through fallback rewrites to App route handlers", async () => {
|
|
const res = await fetch(
|
|
`${baseUrl}/api/pages-fallback-to-app/session/login?client=vinext&mw-auth`,
|
|
{
|
|
headers: { "x-api-fallback-handoff": "preserved" },
|
|
},
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
await expect(res.json()).resolves.toEqual({
|
|
body: null,
|
|
cookie: "mw-api-fallback-user=1",
|
|
header: "preserved",
|
|
pathname: "/api/pages-fallback-to-app/session/login",
|
|
query: { client: "vinext", from: "fallback", "mw-auth": "" },
|
|
slugs: ["session", "login"],
|
|
});
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-static/app-static.test.ts
|
|
it("lets route handlers synchronously catch updateTag errors without crashing", async () => {
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/api/update-tag-error`);
|
|
expect(res.status).toBe(500);
|
|
await expect(res.json()).resolves.toMatchObject({
|
|
error: expect.stringContaining("updateTag can only be called from within a Server Action"),
|
|
});
|
|
|
|
const healthRes = await fetch(`${baseUrl}/api/hello`);
|
|
expect(healthRes.status).toBe(200);
|
|
});
|
|
|
|
it("runs an exact API middleware matcher for a trailing-slash route handler request", async () => {
|
|
const res = await fetch(`${baseUrl}/api/header-override-delete/`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-mw-ran")).toBe("true");
|
|
expect(res.headers.get("x-mw-pathname")).toBe("/api/header-override-delete/");
|
|
});
|
|
|
|
it("returns 404 for nonexistent routes", async () => {
|
|
const res = await fetch(`${baseUrl}/no-such-page`);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
// Faithfully combines two Next.js contracts:
|
|
// - route misses render the root not-found page inside the root layout:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/not-found/basic/index.test.ts
|
|
// - redirect() thrown during an RSC document request becomes a 307 response:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts
|
|
it("handles route-miss root layout redirects in production", async () => {
|
|
const fixtureRoot = await createIsolatedFixture(
|
|
ROOT_LAYOUT_NOT_FOUND_REDIRECT_FIXTURE_DIR,
|
|
"vinext-root-layout-not-found-redirect-",
|
|
);
|
|
const redirectOutDir = path.join(fixtureRoot, "dist");
|
|
let redirectServer: http.Server | undefined;
|
|
const previousPagesClientAssets = getPagesClientAssets();
|
|
const prodGlobalKeys = [
|
|
"__vite_rsc_client_require__",
|
|
"__vite_rsc_require__",
|
|
"__vite_rsc_server_require__",
|
|
"__webpack_chunk_load__",
|
|
"__webpack_require__",
|
|
];
|
|
const previousGlobals = new Map(
|
|
prodGlobalKeys.map((key) => [
|
|
key,
|
|
{
|
|
exists: Reflect.has(globalThis, key),
|
|
value: Reflect.get(globalThis, key),
|
|
},
|
|
]),
|
|
);
|
|
|
|
try {
|
|
const builder = await createBuilder({
|
|
root: fixtureRoot,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: fixtureRoot })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
const started = await startProdServer({
|
|
port: 0,
|
|
outDir: redirectOutDir,
|
|
noCompression: true,
|
|
});
|
|
redirectServer = started.server;
|
|
|
|
const address = redirectServer.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Production redirect fixture did not bind to a TCP port");
|
|
}
|
|
const redirectBaseUrl = `http://127.0.0.1:${address.port}`;
|
|
|
|
const notFoundRes = await fetch(`${redirectBaseUrl}/random-content`);
|
|
expect(notFoundRes.status).toBe(404);
|
|
const html = await notFoundRes.text();
|
|
expect(html).toContain("Root Not Found");
|
|
expect(html).toContain('id="layout-nav"');
|
|
|
|
const redirectRes = await fetch(`${redirectBaseUrl}/random-content`, {
|
|
redirect: "manual",
|
|
headers: {
|
|
"x-vinext-root-layout-redirect": "1",
|
|
},
|
|
});
|
|
expect(redirectRes.status).toBe(307);
|
|
const location = redirectRes.headers.get("location");
|
|
expect(location).toBeTruthy();
|
|
expect(new URL(location!, redirectBaseUrl).pathname).toBe("/result");
|
|
|
|
// RSC navigations encode the redirect in the flight body (200), not a
|
|
// 307 status line — mirrors the dev-server RSC test and pins the built
|
|
// production path through the same boundary redirect-flight builder.
|
|
// vinext routes RSC by the `.rsc` suffix (app-rsc-request-normalization).
|
|
const rscRedirectRes = await fetch(`${redirectBaseUrl}/random-content.rsc`, {
|
|
redirect: "manual",
|
|
headers: {
|
|
"x-vinext-root-layout-redirect": "1",
|
|
Accept: "text/x-component",
|
|
},
|
|
});
|
|
expect(rscRedirectRes.status).toBe(200);
|
|
expect(rscRedirectRes.headers.get("content-type")).toContain("text/x-component");
|
|
expect(rscRedirectRes.headers.get("x-vinext-rsc-redirect")).toBe("/result");
|
|
const rscBody = await rscRedirectRes.text();
|
|
expect(rscBody).toContain("NEXT_REDIRECT");
|
|
expect(rscBody).toContain("/result");
|
|
|
|
// The RSC drain applies to matched-route HTTP-access fallbacks too, not
|
|
// only route misses. `/gated` is a matched route that calls notFound();
|
|
// its route-level not-found boundary (app/gated/not-found.tsx) redirects
|
|
// on its own header, so it renders during the fallback (not the layout
|
|
// probe) and its async redirect is caught by the boundary drain. With the
|
|
// trigger → 200 flight redirect; without it → a normal 404 flight
|
|
// (proving the buffering does not drop or corrupt the matched-route
|
|
// payload).
|
|
const matchedRedirectRes = await fetch(`${redirectBaseUrl}/gated.rsc`, {
|
|
redirect: "manual",
|
|
headers: {
|
|
"x-vinext-gated-notfound-redirect": "1",
|
|
Accept: "text/x-component",
|
|
},
|
|
});
|
|
expect(matchedRedirectRes.status).toBe(200);
|
|
expect(matchedRedirectRes.headers.get("x-vinext-rsc-redirect")).toBe("/result");
|
|
expect(await matchedRedirectRes.text()).toContain("NEXT_REDIRECT");
|
|
|
|
const matchedNotFoundRes = await fetch(`${redirectBaseUrl}/gated.rsc`, {
|
|
redirect: "manual",
|
|
headers: { Accept: "text/x-component" },
|
|
});
|
|
expect(matchedNotFoundRes.status).toBe(404);
|
|
expect(matchedNotFoundRes.headers.get("x-vinext-rsc-redirect")).toBeNull();
|
|
expect(await matchedNotFoundRes.text()).toContain("Gated Not Found");
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (!redirectServer) {
|
|
resolve();
|
|
return;
|
|
}
|
|
redirectServer.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
setPagesClientAssets(previousPagesClientAssets);
|
|
for (const [key, previous] of previousGlobals) {
|
|
if (previous.exists) {
|
|
Reflect.set(globalThis, key, previous.value);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, key);
|
|
}
|
|
}
|
|
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/rsc-redirect/rsc-redirect.test.ts
|
|
//
|
|
// Document request (no `Rsc` header) to a page that calls `redirect()` must
|
|
// respond with HTTP 307 + Location. The RSC variant (`.rsc` URL or Rsc:1
|
|
// header) returns 200 with a flight payload — that path is covered by the
|
|
// sibling `.rsc` redirect tests above and by issue #1347.
|
|
//
|
|
// See: https://github.com/cloudflare/vinext/issues/1530
|
|
it("redirect() from Server Component returns 307 on document load (production)", async () => {
|
|
const res = await fetch(`${baseUrl}/redirect-test`, { redirect: "manual" });
|
|
expect(res.status).toBe(307);
|
|
const location = res.headers.get("location");
|
|
expect(location).toBeTruthy();
|
|
expect(location).toContain("/about");
|
|
});
|
|
|
|
// Issue #1529: an RSC client navigation that hits a next.config.js redirect
|
|
// must keep the cache-busting `_rsc` query on the redirect Location so the
|
|
// browser's auto-followed request to the destination is still treated as an
|
|
// RSC fetch. The vinext client addresses RSC navigations via the `RSC: 1`
|
|
// header + `?_rsc=` query, so we replicate that request shape here.
|
|
it("preserves the _rsc query on config-redirect Location for RSC navigations (#1529)", async () => {
|
|
const res = await fetch(`${baseUrl}/old-about?_rsc=abc123`, {
|
|
redirect: "manual",
|
|
headers: { Accept: "text/x-component", RSC: "1" },
|
|
});
|
|
expect(res.status).toBe(308);
|
|
const location = res.headers.get("location");
|
|
expect(location).toBeTruthy();
|
|
expect(location).toContain("/about");
|
|
// The App Router RSC handler canonicalizes the redirect Location by
|
|
// recomputing the cache-busting `_rsc` param from the request headers
|
|
// (rather than echoing the literal client value), so assert presence.
|
|
expect(location).toContain("_rsc");
|
|
});
|
|
|
|
it("serves static assets with cache headers", async () => {
|
|
// Find an actual hashed JS asset from the build.
|
|
const assetsDir = path.join(outDir, "client", "_next", "static", "chunks");
|
|
const assets = fs.readdirSync(assetsDir);
|
|
const jsFile = assets.find((f: string) => f.endsWith(".js"));
|
|
expect(jsFile).toBeDefined();
|
|
|
|
const res = await fetch(`${baseUrl}/_next/static/chunks/${jsFile}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toContain("javascript");
|
|
expect(res.headers.get("cache-control")).toContain("immutable");
|
|
});
|
|
|
|
it("serves public files from the build output", async () => {
|
|
// Ported from Next.js: test/production/export/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/production/export/index.test.ts
|
|
const res = await fetch(`${baseUrl}/logo/logo.svg`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toContain("image/svg+xml");
|
|
expect(await res.text()).toContain("vinext");
|
|
});
|
|
|
|
it("returns 405 for unsupported methods on existing public files", async () => {
|
|
const res = await fetch(`${baseUrl}/logo/logo.svg`, { method: "POST" });
|
|
|
|
expect(res.status).toBe(405);
|
|
expect(res.headers.get("allow")).toBe("GET, HEAD");
|
|
expect(await res.text()).toBe("Method Not Allowed");
|
|
});
|
|
|
|
it("serves public files under basePath and 404s without it", async () => {
|
|
// Ported from Next.js: test/e2e/basepath/basepath.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/basepath/basepath.test.ts
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-public-basepath-"));
|
|
const fixtureRoot = path.join(tmpDir, "fixture");
|
|
let basePathServer: import("node:http").Server | undefined;
|
|
|
|
try {
|
|
fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true });
|
|
const fixtureNodeModules = path.join(fixtureRoot, "node_modules");
|
|
if (!fs.existsSync(fixtureNodeModules)) {
|
|
fs.symlinkSync(
|
|
path.resolve(__dirname, "..", "node_modules"),
|
|
fixtureNodeModules,
|
|
"junction",
|
|
);
|
|
}
|
|
|
|
const nextConfigPath = path.join(fixtureRoot, "next.config.ts");
|
|
const nextConfig = fs.readFileSync(nextConfigPath, "utf-8");
|
|
fs.writeFileSync(
|
|
nextConfigPath,
|
|
nextConfig.replace(
|
|
"const nextConfig: NextConfig = {",
|
|
'const nextConfig: NextConfig = {\n basePath: "/app",',
|
|
),
|
|
);
|
|
|
|
const builder = await createBuilder({
|
|
root: fixtureRoot,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: fixtureRoot })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server: basePathServer } = await startProdServer({
|
|
port: 0,
|
|
outDir: path.join(fixtureRoot, "dist"),
|
|
noCompression: true,
|
|
}));
|
|
const addr = basePathServer.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
const tmpBaseUrl = `http://localhost:${port}`;
|
|
|
|
const withBasePathRes = await fetch(`${tmpBaseUrl}/app/logo/logo.svg`);
|
|
expect(withBasePathRes.status).toBe(200);
|
|
expect(withBasePathRes.headers.get("content-type")).toContain("image/svg+xml");
|
|
|
|
const withoutBasePathRes = await fetch(`${tmpBaseUrl}/logo/logo.svg`);
|
|
expect(withoutBasePathRes.status).toBe(404);
|
|
} finally {
|
|
basePathServer?.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("supports gzip compression for HTML", async () => {
|
|
const res = await fetch(`${baseUrl}/`, {
|
|
headers: { "Accept-Encoding": "gzip" },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
// Node.js fetch auto-decompresses, but we can check the header
|
|
// was set by looking at the original response headers
|
|
expect(res.headers.get("content-encoding")).toBe("gzip");
|
|
});
|
|
|
|
it("supports brotli compression for HTML", async () => {
|
|
const res = await fetch(`${baseUrl}/`, {
|
|
headers: { "Accept-Encoding": "br" },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-encoding")).toBe("br");
|
|
});
|
|
|
|
it("streams HTML (response is a ReadableStream)", async () => {
|
|
const res = await fetch(`${baseUrl}/`);
|
|
expect(res.status).toBe(200);
|
|
// Verify we can read the body as text (proves streaming works)
|
|
const html = await res.text();
|
|
expect(html.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("flushes the document shell before slow generated metadata resolves", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.7/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
|
|
const response = await fetch(`${baseUrl}/metadata-streaming-timing`, {
|
|
headers: { "user-agent": "HeadlessChrome" },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).not.toBeNull();
|
|
|
|
const reader = response.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
let html = "";
|
|
let shellTime: number | null = null;
|
|
let metadataTime: number | null = null;
|
|
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
html += decoder.decode(value, { stream: true });
|
|
const now = performance.now();
|
|
if (shellTime === null && html.includes("metadata-streaming-shell")) {
|
|
shellTime = now;
|
|
}
|
|
if (metadataTime === null && html.includes("Delayed streaming metadata")) {
|
|
metadataTime = now;
|
|
}
|
|
}
|
|
html += decoder.decode();
|
|
|
|
expect(shellTime).not.toBeNull();
|
|
expect(metadataTime).not.toBeNull();
|
|
expect(metadataTime! - shellTime!).toBeGreaterThan(600);
|
|
expect(html).toContain("<title>Delayed streaming metadata</title>");
|
|
expect(html).toContain('data-testid="metadata-streaming-shell"');
|
|
});
|
|
|
|
it("flushes the RSC navigation shell before slow generated metadata resolves", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/instant-validation/instant-validation.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.7/test/e2e/app-dir/instant-validation/instant-validation.test.ts
|
|
const response = await fetch(`${baseUrl}/metadata-streaming-timing.rsc`, {
|
|
headers: { Accept: "text/x-component", RSC: "1", "user-agent": "HeadlessChrome" },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toContain("text/x-component");
|
|
expect(response.body).not.toBeNull();
|
|
|
|
const reader = response.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
let flight = "";
|
|
let shellTime: number | null = null;
|
|
let metadataTime: number | null = null;
|
|
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
flight += decoder.decode(value, { stream: true });
|
|
const now = performance.now();
|
|
if (shellTime === null && flight.includes("metadata-streaming-shell")) {
|
|
shellTime = now;
|
|
}
|
|
if (metadataTime === null && flight.includes("Delayed streaming metadata")) {
|
|
metadataTime = now;
|
|
}
|
|
}
|
|
flight += decoder.decode();
|
|
|
|
expect(shellTime).not.toBeNull();
|
|
expect(metadataTime).not.toBeNull();
|
|
expect(metadataTime! - shellTime!).toBeGreaterThan(600);
|
|
});
|
|
|
|
it("carries a delayed generateMetadata redirect in the streamed RSC payload", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/metadata-navigation/metadata-navigation.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.7/test/e2e/app-dir/metadata-navigation/metadata-navigation.test.ts#L76-L87
|
|
//
|
|
// RSC navigation starts streaming before generated metadata settles, so a
|
|
// late redirect remains an HTTP 200 and travels in the Flight error digest.
|
|
const response = await fetch(`${baseUrl}/metadata-redirect-test.rsc`, {
|
|
headers: { Accept: "text/x-component", RSC: "1", "user-agent": "HeadlessChrome" },
|
|
redirect: "manual",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toContain("text/x-component");
|
|
|
|
const flight = await response.text();
|
|
expect(flight).toContain("NEXT_REDIRECT;;%2Fabout");
|
|
});
|
|
|
|
it("renders not-found metadata when deferred generateMetadata calls notFound", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.7/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
|
|
const response = await fetch(`${baseUrl}/metadata-streaming-not-found`, {
|
|
headers: { "user-agent": "HeadlessChrome" },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
|
|
const html = await response.text();
|
|
expect(html.match(/<title>/g) ?? []).toHaveLength(1);
|
|
expect(html).toContain("<title>Streamed not-found metadata</title>");
|
|
|
|
const rscResponse = await fetch(`${baseUrl}/metadata-streaming-not-found.rsc`, {
|
|
headers: { Accept: "text/x-component", RSC: "1", "user-agent": "HeadlessChrome" },
|
|
});
|
|
expect(rscResponse.status).toBe(200);
|
|
const flight = await rscResponse.text();
|
|
expect(flight).toContain("Streamed not-found metadata");
|
|
expect(flight).toContain("NEXT_HTTP_ERROR_FALLBACK;404");
|
|
});
|
|
|
|
it("omits searchParams from deferred not-found convention metadata", async () => {
|
|
// Next.js puts page.tsx in a synthetic __PAGE__ loader-tree child, so the
|
|
// containing not-found convention receives layout-style { params } props.
|
|
// Verified against Next.js 16.2.7 production behavior.
|
|
const response = await fetch(
|
|
`${baseUrl}/metadata-streaming-not-found-search-params?source=search`,
|
|
{ headers: { "user-agent": "HeadlessChrome" } },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
|
|
const html = await response.text();
|
|
expect(html).toContain("<title>Streamed not-found search=false source=missing</title>");
|
|
});
|
|
|
|
it("uses an active slot's local not-found metadata convention", async () => {
|
|
// Ported from Next.js loader-tree error-convention traversal:
|
|
// packages/next/src/lib/metadata/resolve-metadata.ts
|
|
const response = await fetch(`${baseUrl}/metadata-streaming-slot-not-found`, {
|
|
headers: { "user-agent": "HeadlessChrome" },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
|
|
const html = await response.text();
|
|
expect(html).toContain("<title>Slot-local not-found metadata</title>");
|
|
expect(html).not.toContain("<title>Primary not-found metadata</title>");
|
|
});
|
|
|
|
it("reports server component render errors via instrumentation in production", async () => {
|
|
const resetRes = await fetch(`${baseUrl}/api/instrumentation-test`, {
|
|
method: "DELETE",
|
|
});
|
|
expect(resetRes.status).toBe(200);
|
|
|
|
const errorRes = await fetch(`${baseUrl}/error-server-test`);
|
|
expect(errorRes.status).toBe(200);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
const stateRes = await fetch(`${baseUrl}/api/instrumentation-test`);
|
|
expect(stateRes.status).toBe(200);
|
|
const state = await stateRes.json();
|
|
|
|
expect(state.errors.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const err = state.errors[state.errors.length - 1];
|
|
expect(err.message).toBe("Server component error");
|
|
expect(err.path).toBe("/error-server-test");
|
|
expect(err.method).toBe("GET");
|
|
expect(err.routerKind).toBe("App Router");
|
|
expect(err.routePath).toBe("/error-server-test");
|
|
expect(err.routeType).toBe("render");
|
|
});
|
|
|
|
it("returns 400 for malformed percent-encoded path (not crash)", async () => {
|
|
const res = await fetch(`${baseUrl}/%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(`${baseUrl}/%`);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.text();
|
|
expect(body).toContain("Bad Request");
|
|
});
|
|
|
|
it("revalidateTag invalidates App Router ISR page entries by fetch tag", async () => {
|
|
const res1 = await fetch(`${baseUrl}/revalidate-tag-test`);
|
|
expect(res1.status).toBe(200);
|
|
const html1 = await res1.text();
|
|
const reqId1 = extractRequestId(html1);
|
|
expect(reqId1).toBeTruthy();
|
|
|
|
const res2 = await fetch(`${baseUrl}/revalidate-tag-test`);
|
|
expect(res2.status).toBe(200);
|
|
const html2 = await res2.text();
|
|
const reqId2 = extractRequestId(html2);
|
|
expect(reqId2).toBe(reqId1);
|
|
expect(res2.headers.get("x-vinext-cache")).toBe("HIT");
|
|
|
|
const tagRes = await fetch(`${baseUrl}/api/revalidate-tag`);
|
|
expect(tagRes.status).toBe(200);
|
|
expect(await tagRes.text()).toBe("ok");
|
|
|
|
const res3 = await fetch(`${baseUrl}/revalidate-tag-test`);
|
|
expect(res3.status).toBe(200);
|
|
const html3 = await res3.text();
|
|
const reqId3 = extractRequestId(html3);
|
|
expect(reqId3).toBeTruthy();
|
|
expect(reqId3).not.toBe(reqId1);
|
|
expect(res3.headers.get("x-vinext-cache")).toBe("MISS");
|
|
});
|
|
|
|
it("revalidatePath invalidates App Router ISR page entries by path tag", async () => {
|
|
const res1 = await fetch(`${baseUrl}/revalidate-tag-test`);
|
|
expect(res1.status).toBe(200);
|
|
const html1 = await res1.text();
|
|
const reqId1 = extractRequestId(html1);
|
|
expect(reqId1).toBeTruthy();
|
|
|
|
const res2 = await fetch(`${baseUrl}/revalidate-tag-test`);
|
|
expect(res2.status).toBe(200);
|
|
const html2 = await res2.text();
|
|
const reqId2 = extractRequestId(html2);
|
|
expect(reqId2).toBe(reqId1);
|
|
expect(res2.headers.get("x-vinext-cache")).toBe("HIT");
|
|
|
|
const pathRes = await fetch(`${baseUrl}/api/revalidate-path`);
|
|
expect(pathRes.status).toBe(200);
|
|
expect(await pathRes.text()).toBe("ok");
|
|
|
|
const res3 = await fetch(`${baseUrl}/revalidate-tag-test`);
|
|
expect(res3.status).toBe(200);
|
|
const html3 = await res3.text();
|
|
const reqId3 = extractRequestId(html3);
|
|
expect(reqId3).toBeTruthy();
|
|
expect(reqId3).not.toBe(reqId1);
|
|
expect(res3.headers.get("x-vinext-cache")).toBe("MISS");
|
|
});
|
|
|
|
describe("revalidatePath with rewrites", () => {
|
|
// Ported from Next.js: test/e2e/app-dir/revalidate-path-with-rewrites/revalidate-path-with-rewrites.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/revalidate-path-with-rewrites/revalidate-path-with-rewrites.test.ts
|
|
//
|
|
// The upstream fixture fetches https://next-data-api-endpoint.vercel.app/api/random.
|
|
// This fixture uses a local text endpoint so the same force-cache/revalidatePath
|
|
// contract is exercised without an external network dependency.
|
|
it("static page should revalidate a static page that was rewritten", async () => {
|
|
await expectRewrittenPathRevalidates("/static");
|
|
});
|
|
|
|
it("dynamic page should revalidate a dynamic page that was rewritten", async () => {
|
|
await expectRewrittenPathRevalidates("/dynamic");
|
|
});
|
|
|
|
// Coverage for the cacheComponents: true path — the sibling tests above exercise
|
|
// the default disabled case. The define contract must stay consistent between the
|
|
// config-load-time environment (used by next.config rewrites) and the bundled
|
|
// boolean expression (used by the route handler).
|
|
it("static page should revalidate a rewritten page with cacheComponents enabled", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-cache-components-rewrite-"));
|
|
const fixtureRoot = path.join(tmpDir, "fixture");
|
|
const ccOutDir = path.join(fixtureRoot, "dist");
|
|
let ccServer: import("node:http").Server | undefined;
|
|
const previousPagesClientAssets = getPagesClientAssets();
|
|
const prodGlobalKeys = [
|
|
"__vite_rsc_client_require__",
|
|
"__vite_rsc_require__",
|
|
"__vite_rsc_server_require__",
|
|
"__webpack_chunk_load__",
|
|
"__webpack_require__",
|
|
];
|
|
const previousGlobals = new Map(
|
|
prodGlobalKeys.map((key) => [
|
|
key,
|
|
{
|
|
exists: Reflect.has(globalThis, key),
|
|
value: Reflect.get(globalThis, key),
|
|
},
|
|
]),
|
|
);
|
|
|
|
try {
|
|
fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, {
|
|
recursive: true,
|
|
filter: (src) => {
|
|
const rel = path.relative(APP_FIXTURE_DIR, src);
|
|
return rel !== "dist" && !rel.startsWith(`dist${path.sep}`);
|
|
},
|
|
});
|
|
fs.rmSync(ccOutDir, { recursive: true, force: true });
|
|
const fixtureNodeModules = path.join(fixtureRoot, "node_modules");
|
|
if (!fs.existsSync(fixtureNodeModules)) {
|
|
fs.symlinkSync(
|
|
path.resolve(__dirname, "..", "node_modules"),
|
|
fixtureNodeModules,
|
|
"junction",
|
|
);
|
|
}
|
|
|
|
const nextConfigPath = path.join(fixtureRoot, "next.config.ts");
|
|
const nextConfig = fs.readFileSync(nextConfigPath, "utf-8");
|
|
fs.writeFileSync(
|
|
nextConfigPath,
|
|
nextConfig.replace(
|
|
"const nextConfig: NextConfig = {",
|
|
"const nextConfig: NextConfig = {\n cacheComponents: true,",
|
|
),
|
|
);
|
|
|
|
// Set the env var so next.config.ts rewrites resolve to the cache-components
|
|
// prefix (the config is evaluated at load time, before the Vite define replaces
|
|
// process.env.__NEXT_CACHE_COMPONENTS with the bundled boolean).
|
|
process.env.__NEXT_CACHE_COMPONENTS = "true";
|
|
|
|
const builder = await createBuilder({
|
|
root: fixtureRoot,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: fixtureRoot })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server: ccServer } = await startProdServer({
|
|
port: 0,
|
|
outDir: ccOutDir,
|
|
noCompression: true,
|
|
}));
|
|
const addr = ccServer.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
const tmpBaseUrl = `http://localhost:${port}`;
|
|
|
|
await expectRewrittenPathRevalidates("/static", tmpBaseUrl);
|
|
await expectRewrittenPathRevalidates("/dynamic", tmpBaseUrl);
|
|
} finally {
|
|
delete process.env.__NEXT_CACHE_COMPONENTS;
|
|
ccServer?.close();
|
|
setPagesClientAssets(previousPagesClientAssets);
|
|
for (const [key, previous] of previousGlobals) {
|
|
if (previous.exists) {
|
|
Reflect.set(globalThis, key, previous.value);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, key);
|
|
}
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 60000);
|
|
});
|
|
|
|
it("dedupes identical no-store fetches across metadata and page render during ISR background regeneration", async () => {
|
|
await withCountingFetchTarget(async (targetUrl, getRequestCount) => {
|
|
process.env.TEST_FETCH_DEDUPE_TARGET = targetUrl;
|
|
try {
|
|
const warmRes = await fetch(`${baseUrl}/fetch-dedupe-isr-metadata`);
|
|
expect(warmRes.status).toBe(200);
|
|
expect(await warmRes.text()).toContain("<title>ISR Product 1</title>");
|
|
expect(getRequestCount()).toBe(1);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
|
|
const staleRes = await fetch(`${baseUrl}/fetch-dedupe-isr-metadata`);
|
|
expect(staleRes.status).toBe(200);
|
|
await staleRes.arrayBuffer();
|
|
|
|
await waitForCondition(() => getRequestCount() > 1, {
|
|
intervalMs: 100,
|
|
timeoutMs: 3000,
|
|
});
|
|
// Poll for count stabilization rather than assuming a fixed window —
|
|
// a stray third fetch would betray dedupe leaking across the
|
|
// metadata + page boundary in background regeneration.
|
|
let stableCount = getRequestCount();
|
|
let stableSince = Date.now();
|
|
while (Date.now() - stableSince < 500) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const current = getRequestCount();
|
|
if (current !== stableCount) {
|
|
stableCount = current;
|
|
stableSince = Date.now();
|
|
}
|
|
}
|
|
|
|
expect(stableCount).toBe(2);
|
|
} finally {
|
|
delete process.env.TEST_FETCH_DEDUPE_TARGET;
|
|
}
|
|
});
|
|
});
|
|
|
|
it("page ISR + searchParams: RSC requests stay dynamic instead of serving cached query data", async () => {
|
|
const res1 = await fetch(`${baseUrl}/isr-dynamic-search.rsc?filter=crimson`, {
|
|
headers: { Accept: "text/x-component", RSC: "1" },
|
|
});
|
|
expect(res1.status).toBe(200);
|
|
expect(res1.headers.get("content-type")).toContain("text/x-component");
|
|
expect(res1.headers.get("x-vinext-cache")).toBeNull();
|
|
const rsc1 = await res1.text();
|
|
expect(rsc1).toContain("crimson");
|
|
|
|
const res2 = await fetch(`${baseUrl}/isr-dynamic-search.rsc?filter=indigo`, {
|
|
headers: { Accept: "text/x-component", RSC: "1" },
|
|
});
|
|
expect(res2.status).toBe(200);
|
|
expect(res2.headers.get("x-vinext-cache")).toBeNull();
|
|
const rsc2 = await res2.text();
|
|
expect(rsc2).toContain("indigo");
|
|
expect(rsc2).not.toContain("crimson");
|
|
});
|
|
|
|
it("page ISR + searchParams: HTML requests also skip ISR caching", async () => {
|
|
const res1 = await fetch(`${baseUrl}/isr-dynamic-search?filter=alpha`);
|
|
expect(res1.status).toBe(200);
|
|
expect(res1.headers.get("x-vinext-cache")).toBeNull();
|
|
const html1 = await res1.text();
|
|
expect(html1).toContain("alpha");
|
|
|
|
const res2 = await fetch(`${baseUrl}/isr-dynamic-search?filter=beta`);
|
|
expect(res2.status).toBe(200);
|
|
expect(res2.headers.get("x-vinext-cache")).toBeNull();
|
|
const html2 = await res2.text();
|
|
expect(html2).toContain("beta");
|
|
expect(html2).not.toContain('"filter">alpha<');
|
|
});
|
|
|
|
// Route handler ISR caching tests
|
|
// These tests are ORDER-DEPENDENT: they share a single production server and
|
|
// /api/static-data cache state persists across tests. HIT depends on MISS
|
|
// having run first, STALE re-warms explicitly. Take care when adding new tests.
|
|
// Fixture: /api/static-data exports revalidate = 1 and returns { timestamp: Date.now() }
|
|
it("route handler ISR: first GET returns MISS", async () => {
|
|
const res = await fetch(`${baseUrl}/api/static-data`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-vinext-cache")).toBe("MISS");
|
|
});
|
|
|
|
it("route handler ISR: second GET returns cached response (HIT)", async () => {
|
|
// First request populates cache
|
|
const res1 = await fetch(`${baseUrl}/api/static-data`);
|
|
const body1 = await res1.json();
|
|
expect(res1.status).toBe(200);
|
|
|
|
// Second request should be a cache hit with identical response
|
|
const res2 = await fetch(`${baseUrl}/api/static-data`);
|
|
const body2 = await res2.json();
|
|
expect(res2.status).toBe(200);
|
|
expect(body2.timestamp).toBe(body1.timestamp);
|
|
expect(res2.headers.get("x-vinext-cache")).toBe("HIT");
|
|
});
|
|
|
|
it("route handler ISR: POST bypasses cache", async () => {
|
|
// POST should never be cached even with revalidate set on GET
|
|
const res = await fetch(`${baseUrl}/api/static-data`, { method: "POST" });
|
|
// /api/static-data only exports GET, POST should be 405
|
|
expect(res.status).toBe(405);
|
|
expect(res.headers.get("x-vinext-cache")).toBeNull();
|
|
});
|
|
|
|
it("route handler ISR: dynamic handler (reads headers()) is not cached", async () => {
|
|
// /api/dynamic-request-data exports revalidate=60 but reads headers() and cookies()
|
|
const res1 = await fetch(`${baseUrl}/api/dynamic-request-data`, {
|
|
headers: { "x-test-ping": "a" },
|
|
});
|
|
const res2 = await fetch(`${baseUrl}/api/dynamic-request-data`, {
|
|
headers: { "x-test-ping": "b" },
|
|
});
|
|
// Dynamic usage should prevent ISR caching
|
|
expect(res1.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(res2.headers.get("x-vinext-cache")).toBeNull();
|
|
});
|
|
|
|
it("route handler ISR: direct request.headers access is not cached", async () => {
|
|
const res1 = await fetch(`${baseUrl}/api/dynamic-request-headers`, {
|
|
headers: { "x-test-ping": "a" },
|
|
});
|
|
const res2 = await fetch(`${baseUrl}/api/dynamic-request-headers`, {
|
|
headers: { "x-test-ping": "b" },
|
|
});
|
|
|
|
expect(await res1.json()).toEqual({ ping: "a" });
|
|
expect(await res2.json()).toEqual({ ping: "b" });
|
|
expect(res1.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(res2.headers.get("x-vinext-cache")).toBeNull();
|
|
});
|
|
|
|
it("route handler ISR: request.url query access is not cached", async () => {
|
|
const res1 = await fetch(`${baseUrl}/api/dynamic-request-url?ping=a`);
|
|
const res2 = await fetch(`${baseUrl}/api/dynamic-request-url?ping=b`);
|
|
|
|
expect(await res1.json()).toEqual({ ping: "a" });
|
|
expect(await res2.json()).toEqual({ ping: "b" });
|
|
expect(res1.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(res2.headers.get("x-vinext-cache")).toBeNull();
|
|
});
|
|
|
|
it("route handler ISR: handler-set Cache-Control skips ISR caching", async () => {
|
|
// /api/custom-cache exports revalidate=60 but sets its own Cache-Control
|
|
const res1 = await fetch(`${baseUrl}/api/custom-cache`);
|
|
const res2 = await fetch(`${baseUrl}/api/custom-cache`);
|
|
// Handler controls caching — ISR should not interfere
|
|
expect(res1.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(res2.headers.get("x-vinext-cache")).toBeNull();
|
|
});
|
|
|
|
it("route handler ISR: force-dynamic handler is not cached", async () => {
|
|
// /api/force-dynamic-revalidate exports revalidate=60 AND dynamic="force-dynamic"
|
|
const res1 = await fetch(`${baseUrl}/api/force-dynamic-revalidate`);
|
|
const res2 = await fetch(`${baseUrl}/api/force-dynamic-revalidate`);
|
|
expect(res1.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(res2.headers.get("x-vinext-cache")).toBeNull();
|
|
});
|
|
|
|
it("route handler ISR: STALE serves stale data and triggers background regen", async () => {
|
|
// /api/static-data has revalidate=1
|
|
// Cache may already be warm from earlier tests — ensure we have a known timestamp
|
|
const warm = await fetch(`${baseUrl}/api/static-data`);
|
|
const warmBody = await warm.json();
|
|
const cachedTimestamp = warmBody.timestamp;
|
|
|
|
// Wait for cache entry to become stale (revalidate=1, generous margin for slow CI)
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
|
|
// STALE — serves stale data, triggers background regen.
|
|
// The stale response must return quickly: it must NOT block on the
|
|
// background regeneration. Measure total duration to catch regressions.
|
|
const staleStart = Date.now();
|
|
const staleRes = await fetch(`${baseUrl}/api/static-data`);
|
|
const staleDuration = Date.now() - staleStart;
|
|
expect(staleRes.headers.get("x-vinext-cache")).toBe("STALE");
|
|
const staleBody = await staleRes.json();
|
|
expect(staleBody.timestamp).toBe(cachedTimestamp); // Still the old data
|
|
|
|
// The stale response must arrive promptly; background regen runs
|
|
// out-of-band via ctx.waitUntil(). Allow 500ms for cold-start latency.
|
|
expect(staleDuration).toBeLessThan(500);
|
|
|
|
// Poll until background regen completes (up to 5s)
|
|
const deadline = Date.now() + 5000;
|
|
let freshRes: Response;
|
|
let freshBody: { timestamp: number };
|
|
do {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
freshRes = await fetch(`${baseUrl}/api/static-data`);
|
|
freshBody = await freshRes.json();
|
|
} while (freshRes.headers.get("x-vinext-cache") !== "HIT" && Date.now() < deadline);
|
|
|
|
// HIT — fresh data from background regen
|
|
expect(freshRes.headers.get("x-vinext-cache")).toBe("HIT");
|
|
expect(freshBody.timestamp).not.toBe(cachedTimestamp); // New data
|
|
});
|
|
|
|
// Test pattern ported from Next.js:
|
|
// test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts
|
|
// (adapted from "use cache" SWR to route handler ISR with export const revalidate)
|
|
it("route handler ISR: STALE completes quickly without blocking on background regen", async () => {
|
|
// /api/slow-isr has revalidate=1 and a 1s handler delay.
|
|
// Populate the cache (cold request, takes ~1s).
|
|
const coldStart = Date.now();
|
|
const cold = await fetch(`${baseUrl}/api/slow-isr`);
|
|
expect(cold.status).toBe(200);
|
|
const coldBody = await cold.json();
|
|
const coldDuration = Date.now() - coldStart;
|
|
expect(coldDuration).toBeGreaterThanOrEqual(700); // roughly 1s handler delay
|
|
|
|
// Wait for the 1s revalidate window to expire.
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
|
|
// Stale request: must return the cached value quickly (< 500ms), not
|
|
// the full 1s handler duration. If the response is blocked on background
|
|
// regeneration, this will take ≥ 1s and fail.
|
|
const staleStart = Date.now();
|
|
const stale = await fetch(`${baseUrl}/api/slow-isr`);
|
|
const staleDuration = Date.now() - staleStart;
|
|
expect(stale.headers.get("x-vinext-cache")).toBe("STALE");
|
|
const staleBody = await stale.json();
|
|
expect(staleBody.timestamp).toBe(coldBody.timestamp); // Still the old data
|
|
expect(staleDuration).toBeLessThan(500);
|
|
|
|
// Wait for background regen to complete, then verify fresh data.
|
|
const deadline = Date.now() + 5000;
|
|
let freshRes: Response;
|
|
let freshBody: { timestamp: number };
|
|
do {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
freshRes = await fetch(`${baseUrl}/api/slow-isr`);
|
|
freshBody = await freshRes.json();
|
|
} while (freshRes.headers.get("x-vinext-cache") !== "HIT" && Date.now() < deadline);
|
|
|
|
expect(freshRes.headers.get("x-vinext-cache")).toBe("HIT");
|
|
expect(freshBody.timestamp).not.toBe(coldBody.timestamp);
|
|
});
|
|
|
|
it("route handler ISR: auto-HEAD returns cached headers with empty body", async () => {
|
|
// Ensure cache is warm
|
|
const getRes = await fetch(`${baseUrl}/api/static-data`);
|
|
await getRes.text();
|
|
const cacheHeader = getRes.headers.get("x-vinext-cache");
|
|
expect(cacheHeader === "MISS" || cacheHeader === "HIT" || cacheHeader === "STALE").toBe(true);
|
|
|
|
// HEAD against a GET-only route should return cached headers, no body
|
|
const headRes = await fetch(`${baseUrl}/api/static-data`, { method: "HEAD" });
|
|
expect(headRes.status).toBe(200);
|
|
expect(headRes.headers.get("x-vinext-cache")).toBe("HIT");
|
|
const body = await headRes.text();
|
|
expect(body).toBe("");
|
|
});
|
|
|
|
// Regression for issue #1453 (route-handler revalidate sub-part): a route
|
|
// handler whose body comes from a `"use cache"` function tagged with
|
|
// `cacheTag()` must be evictable via `revalidateTag()`. `cacheTag()` tags
|
|
// declared inside `"use cache"` were attached only to the inner data cache
|
|
// entry and never propagated to the surrounding route-handler ISR entry, so
|
|
// `revalidateTag()` left the cached route-handler response in place.
|
|
// Fixtures: /api/use-cache-tagged (no revalidate window, tagged via
|
|
// cacheTag("use-cache-rh-tag")) + /api/revalidate-use-cache-rh-tag.
|
|
it("route handler ISR: revalidateTag evicts a 'use cache' cacheTag()-tagged route handler", async () => {
|
|
// Cold request populates the cache.
|
|
const res1 = await fetch(`${baseUrl}/api/use-cache-tagged`);
|
|
expect(res1.status).toBe(200);
|
|
expect(res1.headers.get("x-vinext-cache")).toBe("MISS");
|
|
const body1 = await res1.json();
|
|
|
|
// Second request HITs the cache — there is no revalidate window, so the
|
|
// timestamp is stable until the tag is invalidated.
|
|
const res2 = await fetch(`${baseUrl}/api/use-cache-tagged`);
|
|
expect(res2.headers.get("x-vinext-cache")).toBe("HIT");
|
|
const body2 = await res2.json();
|
|
expect(body2.timestamp).toBe(body1.timestamp);
|
|
|
|
// Invalidate the cacheTag declared inside the "use cache" function.
|
|
const tagRes = await fetch(`${baseUrl}/api/revalidate-use-cache-rh-tag`);
|
|
expect(tagRes.status).toBe(200);
|
|
expect(await tagRes.text()).toBe("ok");
|
|
|
|
// The next request must miss the cache and produce a fresh timestamp.
|
|
const res3 = await fetch(`${baseUrl}/api/use-cache-tagged`);
|
|
expect(res3.headers.get("x-vinext-cache")).toBe("MISS");
|
|
const body3 = await res3.json();
|
|
expect(body3.timestamp).not.toBe(body1.timestamp);
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/use-cache-with-server-function-props
|
|
// ("should be able to use nested cache functions as props").
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-with-server-function-props/use-cache-with-server-function-props.test.ts
|
|
//
|
|
// Inline "use cache" functions defined inside a cached component and passed
|
|
// as props to a client component must (a) serialize as server references in
|
|
// the RSC payload and (b) resolve back through the production
|
|
// server-references manifest on the action POST. (b) can only fail in
|
|
// production builds — the manifest is keyed by the plugin-rsc normalised
|
|
// reference key and generated from serverReferenceMetaMap — so this test
|
|
// must run against the built output, not the dev server.
|
|
it("resolves nested 'use cache' functions passed as props when invoked as actions", async () => {
|
|
const res = await fetch(`${baseUrl}/use-cache-nested-fn-props`);
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
|
|
// React Flight encodes server-function props with the `$h` token used by
|
|
// this React build's SERVER_DECODE_REFERENCE_PREFIX path. Keep this
|
|
// assertion next to the action round-trip so the test proves both halves:
|
|
// the payload uses the server-reference encoding and the decoded reference
|
|
// resolves through vinext's production manifest below.
|
|
expect(html).toContain('\\"getDate\\":\\"$h');
|
|
|
|
// The flight payload embeds each cached function prop as a server
|
|
// reference whose id is "<12-hex normalised key>#<hoisted export name>".
|
|
const refIds = [...new Set(html.match(/[0-9a-f]{12}#\$\$hoist_\d+_[A-Za-z0-9_$]+/g) ?? [])];
|
|
expect(refIds.length).toBe(3);
|
|
const [getDateRefId, getRandomRefId, getMessageRefId] = refIds;
|
|
|
|
// The fixture's getMessage closes over this string. Match Next.js and
|
|
// plugin-rsc's "use server" transform by serializing an encrypted binding,
|
|
// never the plaintext capture, into the Flight payload.
|
|
const capturedScopeValue = "closure-captured-bound-arg-vinext";
|
|
expect(html).not.toContain(capturedScopeValue);
|
|
const encryptedBoundArgs = [
|
|
...new Set(
|
|
[...html.matchAll(/rsc\.push\("[0-9a-f]+:\\"([A-Za-z0-9+/=]{64,})\\"/g)].map(
|
|
(match) => match[1],
|
|
),
|
|
),
|
|
];
|
|
expect(encryptedBoundArgs).toHaveLength(2);
|
|
const encryptedCaptureEnvelopes = encryptedBoundArgs.map((encrypted) => ({
|
|
type: "use-cache-captures",
|
|
encrypted,
|
|
}));
|
|
|
|
const invokeAction = async (actionId: string, args: unknown[] = []): Promise<string> => {
|
|
const actionRes = await fetch(`${baseUrl}/use-cache-nested-fn-props.rsc`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "text/plain",
|
|
"x-rsc-action": actionId,
|
|
},
|
|
body: JSON.stringify(args),
|
|
});
|
|
expect(actionRes.status).toBe(200);
|
|
expect(actionRes.headers.get("x-nextjs-action-not-found")).toBeNull();
|
|
const text = await actionRes.text();
|
|
expect(text).not.toContain("Server action not found");
|
|
return text;
|
|
};
|
|
|
|
const isoDateRegExp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/;
|
|
const date1 = (await invokeAction(getDateRefId)).match(isoDateRegExp)?.[0];
|
|
expect(date1).toBeDefined();
|
|
|
|
// The resolved server reference is the cached wrapper (Next.js parity:
|
|
// the exported cached binding IS the server reference), so a second
|
|
// invocation with identical arguments returns the cached value.
|
|
const date2 = (await invokeAction(getDateRefId)).match(isoDateRegExp)?.[0];
|
|
expect(date2).toBe(date1);
|
|
|
|
const randomText = await invokeAction(getRandomRefId);
|
|
expect(randomText).toMatch(/\d+\.\d+/);
|
|
|
|
// Closure round-trip: the client sends the encrypted binding ahead of the
|
|
// call args. The server-reference wrapper decrypts it before entering the
|
|
// cached function, so plaintext values still determine the cache key.
|
|
const messageRegExpFor = (boundArg: string): RegExp =>
|
|
new RegExp(`message:${boundArg}:[0-9.e+-]+`);
|
|
const resolveCapturedMessage = async () => {
|
|
for (const envelope of encryptedCaptureEnvelopes) {
|
|
const response = await invokeAction(getMessageRefId, [envelope]);
|
|
const message = response.match(messageRegExpFor(capturedScopeValue))?.[0];
|
|
if (message) {
|
|
return { envelope, message };
|
|
}
|
|
}
|
|
};
|
|
const capturedMessage = await resolveCapturedMessage();
|
|
if (!capturedMessage) {
|
|
throw new Error(`No encrypted binding resolved to ${capturedScopeValue}`);
|
|
}
|
|
|
|
// Cached-invoke semantics for the closure-BOUND path, mirroring the
|
|
// getDate assertion above so caching is pinned across both paths
|
|
// (unbound getDate AND bound getMessage): the fixture appends a
|
|
// Math.random() suffix, so a second invocation with the same bound arg
|
|
// can only return the identical value if the bound arg produced the same
|
|
// cache key and the entry was hit (a recompute would change the suffix).
|
|
const message2 = (await invokeAction(getMessageRefId, [capturedMessage.envelope])).match(
|
|
messageRegExpFor(capturedScopeValue),
|
|
)?.[0];
|
|
expect(message2).toBe(capturedMessage.message);
|
|
});
|
|
|
|
it("middleware request header overrides still apply after middleware calls headers() first", async () => {
|
|
// Regression for a bug where a middleware that reads `next/headers` →
|
|
// `headers()` *before* returning `NextResponse.next({ request: { headers } })`
|
|
// leaked the pre-override snapshot into the Server Component.
|
|
//
|
|
// The `headers()` call cached the sealed read-only Headers view on the
|
|
// shared HeadersContext (`ctx.readonlyHeaders = _sealHeaders(ctx.headers)`).
|
|
// `applyMiddlewareRequestHeaders()` then replaced `ctx.headers` with the
|
|
// override view but did not invalidate the cached sealed snapshot, so the
|
|
// Server Component's subsequent `headers()` call returned the original
|
|
// pre-override request headers.
|
|
//
|
|
// Discovered with @clerk/nextjs, whose `clerkClient()` calls
|
|
// `await headers()` via its internal `buildRequestLike()` helper during
|
|
// middleware execution. Clerk's `auth()` in a Server Component then threw
|
|
//
|
|
// "auth() was called but Clerk can't detect usage of clerkMiddleware()"
|
|
//
|
|
// because Clerk's own x-clerk-auth-* request header overrides never
|
|
// reached the render. The fixture middleware reproduces the same prime-
|
|
// then-override sequence without a Clerk dependency by calling
|
|
// `await headers()` first and then returning the override response.
|
|
//
|
|
// The test runs against the production server (startProdServer) because
|
|
// the bug only manifests on the inline RSC entry path that wraps the
|
|
// entire request — including middleware execution — in the headers
|
|
// context. The dev-mode middleware path runs middleware before the
|
|
// headers context exists, so calling `headers()` from middleware is
|
|
// instead an immediate error there.
|
|
const res = await fetch(`${baseUrl}/header-override-after-prior-access`, {
|
|
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<');
|
|
expect(html).toContain('id="cookie-count">0<');
|
|
});
|
|
|
|
// Regression for cloudflare/vinext#1480: a node-runtime middleware that
|
|
// matches the action path and reads the request body on POST (then falls
|
|
// through with `NextResponse.next()`) must not prevent the server-action
|
|
// POST from being intercepted and executed. The app-basic middleware matches
|
|
// `/nextjs-compat/action-node-mw` and consumes the body for POSTs, mirroring
|
|
// Next.js' `middleware-node.js`. This runs against the production server
|
|
// because the upstream failure surfaced in the deploy suite.
|
|
it("dispatches a server action POST under a body-reading node middleware", async () => {
|
|
const html = await (await fetch(`${baseUrl}/nextjs-compat/action-node-mw`)).text();
|
|
// Production action ids are hashed; extract the id from the bound form's
|
|
// `$ACTION_1:0` reference payload rather than hardcoding it.
|
|
const refValue = html.match(/name="\$ACTION_[^"]*:0"\s+value="([^"]+)"/)?.[1];
|
|
expect(refValue).toBeDefined();
|
|
const decoded = refValue!.replace(/"/g, '"').replace(/&/g, "&");
|
|
const actionId = JSON.parse(decoded).id as string;
|
|
expect(actionId).toBeTruthy();
|
|
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/action-node-mw.rsc`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "text/plain",
|
|
"x-rsc-action": actionId,
|
|
},
|
|
body: JSON.stringify(["world"]),
|
|
});
|
|
const text = await res.text();
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-nextjs-action-not-found")).toBeNull();
|
|
expect(text).not.toContain("Server action not found");
|
|
expect(text).toContain("echo:world");
|
|
});
|
|
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/actions/app-action-size-limit-invalid.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/actions/app-action-size-limit-invalid.test.ts
|
|
it("returns a Flight error for a streamed server action body overflow", async () => {
|
|
const html = await (await fetch(`${baseUrl}/nextjs-compat/action-node-mw`)).text();
|
|
const refValue = html.match(/name="\$ACTION_[^"]*:0"\s+value="([^"]+)"/)?.[1];
|
|
expect(refValue).toBeDefined();
|
|
const decoded = refValue!.replace(/"/g, '"').replace(/&/g, "&");
|
|
const actionId = JSON.parse(decoded).id as string;
|
|
|
|
const oversizedPayload = JSON.stringify(["x".repeat(2 * 1024 * 1024)]);
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const encoded = new TextEncoder().encode(oversizedPayload);
|
|
for (let offset = 0; offset < encoded.byteLength; offset += 64 * 1024) {
|
|
controller.enqueue(encoded.subarray(offset, offset + 64 * 1024));
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
const res = await fetch(`${baseUrl}/nextjs-compat/action-node-mw.rsc`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "text/plain",
|
|
"x-rsc-action": actionId,
|
|
},
|
|
body,
|
|
duplex: "half",
|
|
} as RequestInit & { duplex: "half" });
|
|
const text = await res.text();
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.headers.get("content-type")).toContain("text/x-component");
|
|
expect(text).not.toContain("echo:");
|
|
});
|
|
});
|
|
|
|
describe("App Router production server entry module identity", () => {
|
|
// Regression test for cloudflare/vinext#1923.
|
|
//
|
|
// Chunks emitted by default Vite/Rolldown builds import the server entry
|
|
// back by its bare path: modules shared between the entry's static graph
|
|
// (middleware, instrumentation) and lazy
|
|
// route chunks are hoisted into the entry chunk, and the lazy chunks then
|
|
// import them via "../../index.js" (e.g. a plain `vite@8.0.16` SSR build
|
|
// of an entry-shared module emits `import { t as shared } from
|
|
// "../entry.js"` in the lazy chunk).
|
|
// Node keys its ESM cache on the full URL *including the query string*, so
|
|
// while the production server imported the entry as `index.js?t=<mtime>`,
|
|
// a chunk's bare back-import evaluated the entire server bundle a second
|
|
// time. Module-level singletons (db pools, service registries) then
|
|
// silently diverged between the two copies: boot-time initialisation ran
|
|
// on the server's instance while route handlers read the duplicate.
|
|
//
|
|
// The Vite+ toolchain this repo builds with enables rolldown's
|
|
// shared-chunk extraction, so this fixture build does not naturally emit
|
|
// the back-import. Recreate the default-Vite layout by prepending a bare
|
|
// entry import to the lazy route chunk before starting the server.
|
|
it("evaluates the server bundle once even when a chunk imports the entry back by bare path", async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prod-entry-identity-"));
|
|
const fixtureRoot = path.join(tmpDir, "fixture");
|
|
const outDir = path.join(fixtureRoot, "dist");
|
|
let server: import("node:http").Server | undefined;
|
|
const EVAL_COUNT_KEY = "__vinext_test_entry_evaluations__";
|
|
const previousPagesClientAssets = getPagesClientAssets();
|
|
const prodGlobalKeys = [
|
|
"__vite_rsc_client_require__",
|
|
"__vite_rsc_require__",
|
|
"__vite_rsc_server_require__",
|
|
"__webpack_chunk_load__",
|
|
"__webpack_require__",
|
|
];
|
|
const previousGlobals = new Map(
|
|
prodGlobalKeys.map((key) => [
|
|
key,
|
|
{
|
|
exists: Reflect.has(globalThis, key),
|
|
value: Reflect.get(globalThis, key),
|
|
},
|
|
]),
|
|
);
|
|
|
|
try {
|
|
fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true });
|
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
const fixtureNodeModules = path.join(fixtureRoot, "node_modules");
|
|
if (!fs.existsSync(fixtureNodeModules)) {
|
|
fs.symlinkSync(
|
|
path.resolve(__dirname, "..", "node_modules"),
|
|
fixtureNodeModules,
|
|
"junction",
|
|
);
|
|
}
|
|
|
|
const builder = await createBuilder({
|
|
root: fixtureRoot,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: fixtureRoot })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
|
|
const serverDir = path.join(outDir, "server");
|
|
const entryPath = path.join(serverDir, "index.js");
|
|
|
|
// Count evaluations of the server entry chunk itself. Imports hoist, so
|
|
// the prepended statement runs first in the entry's own body each time
|
|
// the module is evaluated.
|
|
fs.writeFileSync(
|
|
entryPath,
|
|
`globalThis.${EVAL_COUNT_KEY} = [...(globalThis.${EVAL_COUNT_KEY} ?? []), import.meta.url];\n` +
|
|
fs.readFileSync(entryPath, "utf-8"),
|
|
);
|
|
|
|
// Recreate the Rollup chunk layout: the lazy chunk for
|
|
// /api/prod-singleton imports the entry back by bare path.
|
|
const staticDir = path.join(serverDir, "_next", "static");
|
|
const routeChunkNames = fs
|
|
.readdirSync(staticDir)
|
|
.filter(
|
|
(name) =>
|
|
name.endsWith(".js") &&
|
|
fs
|
|
.readFileSync(path.join(staticDir, name), "utf-8")
|
|
.includes("x-vinext-prod-singleton"),
|
|
);
|
|
expect(routeChunkNames).toHaveLength(1);
|
|
const routeChunkPath = path.join(staticDir, routeChunkNames[0]);
|
|
fs.writeFileSync(
|
|
routeChunkPath,
|
|
`import "../../index.js";\n` + fs.readFileSync(routeChunkPath, "utf-8"),
|
|
);
|
|
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server } = await startProdServer({ port: 0, outDir, noCompression: true }));
|
|
const addr = server.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
|
|
expect(Reflect.get(globalThis, EVAL_COUNT_KEY)).toHaveLength(1);
|
|
|
|
const res = await fetch(`http://localhost:${port}/api/prod-singleton`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("x-vinext-prod-singleton")).toBe("1");
|
|
const body = await res.json();
|
|
|
|
// Loading the lazy route chunk pulled in the bare "../../index.js"
|
|
// import; the server bundle must still have been evaluated only once.
|
|
// (The recorded values are the import.meta.url of each evaluation, so
|
|
// a failure shows which URLs the two module instances were keyed on.)
|
|
expect(Reflect.get(globalThis, EVAL_COUNT_KEY)).toHaveLength(1);
|
|
|
|
// The route handler reads the same module-level singleton instance
|
|
// that boot-time instrumentation register() initialised. A duplicate
|
|
// bundle instance would report null.
|
|
expect(body).toEqual({ initializedBy: "instrumentation-register" });
|
|
} finally {
|
|
server?.close();
|
|
Reflect.deleteProperty(globalThis, EVAL_COUNT_KEY);
|
|
setPagesClientAssets(previousPagesClientAssets);
|
|
for (const [key, previous] of previousGlobals) {
|
|
if (previous.exists) {
|
|
Reflect.set(globalThis, key, previous.value);
|
|
} else {
|
|
Reflect.deleteProperty(globalThis, key);
|
|
}
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 120000);
|
|
});
|