mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
2295 lines
89 KiB
TypeScript
2295 lines
89 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { toSlash } from "pathslash";
|
|
|
|
const closeMock = vi.hoisted(() => vi.fn((callback: () => void) => callback()));
|
|
const startProdServerMock = vi.hoisted(() =>
|
|
vi.fn(async () => ({ server: { close: closeMock }, port: 43210 })),
|
|
);
|
|
|
|
vi.mock("../packages/vinext/src/server/prod-server.js", () => ({
|
|
startProdServer: startProdServerMock,
|
|
}));
|
|
|
|
let tmpDir: string;
|
|
|
|
function writeFile(relativePath: string, content: string): void {
|
|
const fullPath = path.join(tmpDir, relativePath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content, "utf-8");
|
|
}
|
|
|
|
describe("prerender path manifest", () => {
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prerender-paths-test-"));
|
|
closeMock.mockClear();
|
|
startProdServerMock.mockClear();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const rawUrl =
|
|
input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
|
const url = new URL(rawUrl);
|
|
if (
|
|
url.pathname === "/__vinext/prerender/static-params" &&
|
|
url.searchParams.get("pattern") === "/cached/:slug"
|
|
) {
|
|
return Response.json([{ slug: "intro" }, { slug: "featured" }]);
|
|
}
|
|
if (
|
|
url.pathname === "/__vinext/prerender/static-params" &&
|
|
url.searchParams.get("pattern") === "/policy/:slug"
|
|
) {
|
|
return Response.json([{ slug: "ordinary" }, { slug: "special" }]);
|
|
}
|
|
if (
|
|
url.pathname === "/__vinext/prerender/static-params" &&
|
|
url.searchParams.get("pattern") === "/unlisted/:slug"
|
|
) {
|
|
return Response.json([{ slug: "known" }]);
|
|
}
|
|
if (
|
|
url.pathname === "/__vinext/prerender/static-params" &&
|
|
url.searchParams.get("pattern") === "/:path+"
|
|
) {
|
|
return Response.json([
|
|
{ path: ["pages-dir", "foobar"] },
|
|
{ path: ["pages-dir", "static"] },
|
|
{ path: ["api", "status"] },
|
|
{ path: ["specific", "value"] },
|
|
]);
|
|
}
|
|
if (
|
|
url.pathname === "/__vinext/prerender/static-params" &&
|
|
url.searchParams.get("pattern") === "/:locale/about"
|
|
) {
|
|
return Response.json([{ locale: "fr" }]);
|
|
}
|
|
if (
|
|
url.pathname === "/__vinext/prerender/static-params" &&
|
|
url.searchParams.get("pattern") === "/:locale/api/status"
|
|
) {
|
|
return Response.json([{ locale: "fr" }]);
|
|
}
|
|
if (
|
|
url.pathname === "/__vinext/prerender/static-params" &&
|
|
url.searchParams.get("pattern") === "/:category"
|
|
) {
|
|
return Response.json([{ category: "news" }]);
|
|
}
|
|
if (
|
|
url.pathname === "/__vinext/prerender/static-params" &&
|
|
url.searchParams.get("pattern") === "/api/items/:slug"
|
|
) {
|
|
return Response.json([{ slug: "one" }, { slug: "two" }]);
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("writes concrete warmup paths without rendering page artifacts", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
"export const revalidate = 60;",
|
|
"export function generateStaticParams() { return [{ slug: 'intro' }, { slug: 'featured' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile("app/cached/loading.tsx", "export default function Loading() { return null; }\n");
|
|
writeFile(
|
|
"app/dynamic/page.tsx",
|
|
"export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n",
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
buildIdentity: "response-header",
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest).toEqual({
|
|
appPaths: ["/", "/dynamic", "/cached/intro", "/cached/featured"],
|
|
buildId: "build-a",
|
|
buildIdentity: "rsc-build-a",
|
|
loadingShellPaths: ["/cached/intro", "/cached/featured"],
|
|
rscBuildId: "rsc-build-a",
|
|
responseVary: "verbatim",
|
|
routePatterns: {
|
|
"/": {
|
|
cacheabilityProbe: { canPrunePattern: true },
|
|
kind: "app-page",
|
|
pattern: "/",
|
|
},
|
|
"/cached/featured": {
|
|
cacheabilityProbe: { canPrunePattern: true },
|
|
kind: "app-page",
|
|
pattern: "/cached/:slug",
|
|
},
|
|
"/cached/intro": {
|
|
cacheabilityProbe: { canPrunePattern: true },
|
|
kind: "app-page",
|
|
pattern: "/cached/:slug",
|
|
},
|
|
"/dynamic": {
|
|
cacheabilityProbe: { canPrunePattern: true },
|
|
kind: "app-page",
|
|
pattern: "/dynamic",
|
|
},
|
|
},
|
|
rscPaths: ["/", "/dynamic", "/cached/intro", "/cached/featured"],
|
|
trailingSlash: false,
|
|
paths: ["/", "/dynamic", "/cached/intro", "/cached/featured"],
|
|
});
|
|
expect(fs.existsSync(path.join(tmpDir, "dist/server/prerendered-routes"))).toBe(false);
|
|
expect(
|
|
JSON.parse(
|
|
fs.readFileSync(path.join(tmpDir, "dist/server/vinext-prerender-paths.json"), "utf-8"),
|
|
),
|
|
).toEqual(manifest);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"http://127.0.0.1:43210/__vinext/prerender/static-params?pattern=%2Fcached%2F%3Aslug",
|
|
expect.any(Object),
|
|
);
|
|
expect(startProdServerMock).toHaveBeenCalledOnce();
|
|
expect(startProdServerMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
rscEntryPath: toSlash(path.join(tmpDir, "dist/server/index.js")),
|
|
}),
|
|
);
|
|
expect(closeMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("marks probe collapses unsafe when config cache policy varies by path or request", async () => {
|
|
// Next.js applies pathname-specific custom Cache-Control to dynamic App
|
|
// routes and evaluates has/missing conditions against each request:
|
|
// test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts
|
|
// test/e2e/custom-routes/custom-routes.test.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/policy/[slug]/page.tsx",
|
|
[
|
|
"export const dynamic = 'force-dynamic';",
|
|
"export function generateStaticParams() { return [{ slug: 'ordinary' }, { slug: 'special' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile(
|
|
"app/conditional/page.tsx",
|
|
"export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n",
|
|
);
|
|
writeFile("app/cookie/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile(
|
|
"app/unlisted/[slug]/page.tsx",
|
|
[
|
|
"export const dynamic = 'force-dynamic';",
|
|
"export function generateStaticParams() { return [{ slug: 'known' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile("app/wildcard/path/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile(
|
|
"next.config.mjs",
|
|
[
|
|
"export default {",
|
|
" headers: async () => [",
|
|
" { source: '/policy/special', headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },",
|
|
" { source: '/conditional', missing: [{ type: 'query', key: '_rsc' }], headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },",
|
|
" { source: '/cookie', has: [{ type: 'query', key: '_rsc', value: '.*' }], headers: [{ key: 'Set-Cookie', value: 'rsc=1' }] },",
|
|
" { source: '/unlisted/public-only', headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },",
|
|
" { source: '/wildcard/*', missing: [{ type: 'query', key: '_rsc', value: '.*' }], headers: [{ key: 'Cache-Control', value: 's-maxage=60' }] },",
|
|
" ],",
|
|
"};",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
buildIdentity: "response-header",
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.routePatterns).toMatchObject({
|
|
"/conditional": {
|
|
cacheabilityProbe: { canPrunePattern: false },
|
|
},
|
|
"/cookie": {
|
|
cacheabilityProbe: { canPrunePattern: true },
|
|
},
|
|
"/policy/ordinary": {
|
|
cacheabilityProbe: { canPrunePattern: false },
|
|
},
|
|
"/policy/special": {
|
|
cacheabilityProbe: { canPrunePattern: false },
|
|
},
|
|
"/unlisted/known": {
|
|
cacheabilityProbe: { canPrunePattern: false },
|
|
},
|
|
"/wildcard/path": {
|
|
cacheabilityProbe: { canPrunePattern: false },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not prune a pattern whose adapter cache policy varies by pathname", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/policy/[slug]/page.tsx",
|
|
[
|
|
"export const dynamic = 'force-dynamic';",
|
|
"export function generateStaticParams() { return [{ slug: 'ordinary' }, { slug: 'special' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile(
|
|
"next.config.mjs",
|
|
[
|
|
"export default {",
|
|
" headers: async () => [{",
|
|
" source: '/policy/special',",
|
|
" headers: [{ key: 'CDN-Cache-Control', value: 'public, s-maxage=60' }],",
|
|
" }],",
|
|
"};",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
buildIdentity: "response-header",
|
|
responsePolicyHeaderNames: ["CDN-Cache-Control"],
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.routePatterns).toMatchObject({
|
|
"/policy/ordinary": { cacheabilityProbe: { canPrunePattern: false } },
|
|
"/policy/special": { cacheabilityProbe: { canPrunePattern: false } },
|
|
});
|
|
});
|
|
|
|
it("discovers only Next.js-static Route Handler GET identities", async () => {
|
|
// Ported from Next.js static eligibility and dynamic Route Handler params:
|
|
// packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts
|
|
// test/e2e/app-dir/app-static/app-static.test.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/api/static/route.ts",
|
|
"export const revalidate = 60; export function GET() { return Response.json({ ok: true }); }",
|
|
);
|
|
writeFile(
|
|
"app/api/dynamic/route.ts",
|
|
"export function GET(request) { return Response.json({ url: request.url }); }",
|
|
);
|
|
writeFile(
|
|
"app/api/items/[slug]/route.ts",
|
|
[
|
|
"export const revalidate = false;",
|
|
"export function generateStaticParams() { return [{ slug: 'one' }, { slug: 'two' }]; }",
|
|
"export function GET(_request, { params }) { return Response.json(params); }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
buildIdentity: "response-header",
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.routeHandlerPaths).toEqual([
|
|
"/api/static",
|
|
"/api/items/one",
|
|
"/api/items/two",
|
|
]);
|
|
expect(manifest?.paths).toEqual([]);
|
|
expect(manifest?.rscPaths).toEqual([]);
|
|
});
|
|
|
|
it("discovers dynamic paths from an uploaded Worker without loading its bundle in Node", async () => {
|
|
// No Next.js test port applies: staged Worker version overrides and
|
|
// cloudflare:workers bindings are Cloudflare-specific.
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", 'import { env } from "cloudflare:workers";\n');
|
|
writeFile(
|
|
"dist/server/vinext-server.json",
|
|
JSON.stringify({ prerenderSecret: "staged-discovery-secret" }),
|
|
);
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
'import { env } from "cloudflare:workers";',
|
|
"export const revalidate = 60;",
|
|
"export async function generateStaticParams() { await env.KV.get('paths'); return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
pathDiscoveryTarget: {
|
|
baseUrl: "https://workers-cache.example.workers.dev/ignored-prefix",
|
|
headers: {
|
|
"Cloudflare-Workers-Version-Overrides": 'workers-cache="version-b"',
|
|
"X-Vinext-Expected-Worker-Version": "version-b",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/cached/intro", "/cached/featured"]);
|
|
expect(startProdServerMock).not.toHaveBeenCalled();
|
|
const [requestUrl, requestInit] = vi.mocked(fetch).mock.calls[0];
|
|
const requestHeaders = new Headers(requestInit?.headers);
|
|
expect(requestUrl).toBe(
|
|
"https://workers-cache.example.workers.dev/__vinext/prerender/static-params?pattern=%2Fcached%2F%3Aslug",
|
|
);
|
|
expect(requestHeaders.get("Cloudflare-Workers-Version-Overrides")).toBe(
|
|
'workers-cache="version-b"',
|
|
);
|
|
expect(requestHeaders.get("X-Vinext-Expected-Worker-Version")).toBe("version-b");
|
|
expect(requestHeaders.get("x-vinext-prerender-secret")).toBe("staged-discovery-secret");
|
|
});
|
|
|
|
it("retries transient staged-version routing failures during remote discovery", async () => {
|
|
// No Next.js test port applies: Worker version propagation is Cloudflare-specific.
|
|
const remoteFetch = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(new Response("old Worker", { status: 404 }))
|
|
.mockResolvedValueOnce(new Response("version still propagating", { status: 500 }))
|
|
.mockResolvedValueOnce(Response.json([{ slug: "intro" }]));
|
|
vi.stubGlobal("fetch", remoteFetch);
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", 'import { env } from "cloudflare:workers";\n');
|
|
writeFile(
|
|
"dist/server/vinext-server.json",
|
|
JSON.stringify({ prerenderSecret: "staged-discovery-secret" }),
|
|
);
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
"export const revalidate = 60;",
|
|
"export async function generateStaticParams() { return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
pathDiscoveryTarget: {
|
|
baseUrl: "https://workers-cache.example.workers.dev",
|
|
retries: 2,
|
|
retryDelayMs: 0,
|
|
},
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/cached/intro"]);
|
|
expect(remoteFetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("uses the full discovery phase when no retry limit is configured", async () => {
|
|
// No Next.js test port applies: Worker version propagation is Cloudflare-specific.
|
|
let attempts = 0;
|
|
const remoteFetch = vi.fn<typeof fetch>(async () => {
|
|
attempts++;
|
|
return attempts <= 61
|
|
? new Response("old Worker", { status: 404 })
|
|
: Response.json([{ slug: "intro" }]);
|
|
});
|
|
vi.stubGlobal("fetch", remoteFetch);
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", 'import { env } from "cloudflare:workers";\n');
|
|
writeFile(
|
|
"dist/server/vinext-server.json",
|
|
JSON.stringify({ prerenderSecret: "staged-discovery-secret" }),
|
|
);
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
"export async function generateStaticParams() { return []; } export default function Page() { return null; }",
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
pathDiscoveryTarget: {
|
|
baseUrl: "https://workers-cache.example.workers.dev",
|
|
phaseTimeoutMs: 120_000,
|
|
retryDelayMs: 0,
|
|
},
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/cached/intro"]);
|
|
expect(remoteFetch).toHaveBeenCalledTimes(62);
|
|
});
|
|
|
|
it("reports exhausted discovery attempts and the last transient status", async () => {
|
|
// No Next.js test port applies: Worker version propagation is Cloudflare-specific.
|
|
const remoteFetch = vi.fn<typeof fetch>(
|
|
async () => new Response("old Worker", { status: 503 }),
|
|
);
|
|
vi.stubGlobal("fetch", remoteFetch);
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", 'import { env } from "cloudflare:workers";\n');
|
|
writeFile(
|
|
"dist/server/vinext-server.json",
|
|
JSON.stringify({ prerenderSecret: "staged-discovery-secret" }),
|
|
);
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
"export async function generateStaticParams() { return []; } export default function Page() { return null; }",
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
await expect(
|
|
emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
pathDiscoveryTarget: {
|
|
baseUrl: "https://workers-cache.example.workers.dev",
|
|
retries: 1,
|
|
retryDelayMs: 0,
|
|
},
|
|
}),
|
|
).rejects.toThrow(
|
|
"path discovery returned HTTP 503 after 2 attempt(s); last transient status was HTTP 503",
|
|
);
|
|
expect(remoteFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("bounds all remote discovery retries by one phase deadline", async () => {
|
|
// No Next.js test port applies: staged Worker discovery is Cloudflare-specific.
|
|
const remoteFetch = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(new Response("old Worker", { status: 503 }))
|
|
.mockResolvedValueOnce(new Response("old Worker", { status: 503 }))
|
|
// The phase deadline remains authoritative even if fetch ignores abort.
|
|
.mockImplementation(() => new Promise<Response>(() => {}));
|
|
vi.stubGlobal("fetch", remoteFetch);
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", 'import { env } from "cloudflare:workers";\n');
|
|
writeFile(
|
|
"dist/server/vinext-server.json",
|
|
JSON.stringify({ prerenderSecret: "staged-discovery-secret" }),
|
|
);
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
"export async function generateStaticParams() { return []; } export default function Page() { return null; }",
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const discovery = emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
pathDiscoveryTarget: {
|
|
baseUrl: "https://workers-cache.example.workers.dev",
|
|
phaseTimeoutMs: 100,
|
|
retries: 60,
|
|
retryDelayMs: 10,
|
|
},
|
|
});
|
|
await expect(discovery).rejects.toThrow(
|
|
"remote path discovery exceeded its 100ms phase deadline",
|
|
);
|
|
expect(remoteFetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("does not replay user-code failures during remote discovery", async () => {
|
|
// No Next.js test port applies: staged Worker discovery is Cloudflare-specific.
|
|
const remoteFetch = vi.fn<typeof fetch>(async () =>
|
|
Response.json({ error: "binding lookup failed" }, { status: 500 }),
|
|
);
|
|
vi.stubGlobal("fetch", remoteFetch);
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"dist/server/vinext-server.json",
|
|
JSON.stringify({ prerenderSecret: "staged-discovery-secret" }),
|
|
);
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
"export async function generateStaticParams() { return []; } export default function Page() { return null; }",
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
await expect(
|
|
emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
pathDiscoveryTarget: {
|
|
baseUrl: "https://workers-cache.example.workers.dev",
|
|
retries: 3,
|
|
retryDelayMs: 0,
|
|
},
|
|
}),
|
|
).rejects.toThrow("path discovery returned HTTP 500: binding lookup failed");
|
|
expect(remoteFetch).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("discovers binding-backed Pages Router paths from the uploaded Worker", async () => {
|
|
// No Next.js test port applies: staged Worker version overrides and
|
|
// cloudflare:workers bindings are Cloudflare-specific.
|
|
const remoteFetch = vi.fn<typeof fetch>(async () =>
|
|
Response.json({ fallback: false, paths: [{ params: { slug: "intro" } }] }),
|
|
);
|
|
vi.stubGlobal("fetch", remoteFetch);
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "pages-build-a\n");
|
|
writeFile("dist/server/entry.js", 'import { env } from "cloudflare:workers";\n');
|
|
writeFile(
|
|
"dist/server/vinext-server.json",
|
|
JSON.stringify({ prerenderSecret: "pages-discovery-secret" }),
|
|
);
|
|
writeFile(
|
|
"pages/posts/[slug].tsx",
|
|
[
|
|
'import { env } from "cloudflare:workers";',
|
|
"export async function getStaticPaths() { await env.KV.get('paths'); return { fallback: false, paths: [] }; }",
|
|
"export function getStaticProps() { return { props: {} }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
pathDiscoveryTarget: {
|
|
baseUrl: "https://pages-cache.example.workers.dev",
|
|
headers: { "Cloudflare-Workers-Version-Overrides": 'pages-cache="version-b"' },
|
|
},
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/posts/intro"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/posts/intro"]);
|
|
expect(startProdServerMock).not.toHaveBeenCalled();
|
|
const [requestUrl, requestInit] = remoteFetch.mock.calls[0];
|
|
expect(requestUrl).toBe(
|
|
"https://pages-cache.example.workers.dev/__vinext/prerender/pages-static-paths?pattern=%2Fposts%2F%3Aslug",
|
|
);
|
|
expect(new Headers(requestInit?.headers).get("x-vinext-prerender-secret")).toBe(
|
|
"pages-discovery-secret",
|
|
);
|
|
});
|
|
|
|
it("discovers strict-Vary RSC paths without consulting completed prerender output", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/page.tsx", "export const revalidate = 60; export default function Page() {}\n");
|
|
writeFile(
|
|
"dist/server/vinext-prerender.json",
|
|
JSON.stringify({
|
|
buildId: "old-build",
|
|
routes: [
|
|
{
|
|
route: "/old",
|
|
status: "rendered",
|
|
router: "app",
|
|
revalidate: 60,
|
|
fallback: false,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.rscPaths).toEqual(["/"]);
|
|
expect(manifest?.rscBuildId).toBe("rsc-build-a");
|
|
});
|
|
|
|
it("excludes App RSC warm paths affected by configured rewrites", async () => {
|
|
// Rewrite-aware prefetches can resolve a public URL to a different route:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/rewrite-me/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile("app/rewrite-me/loading.tsx", "export default function Loading() { return null; }\n");
|
|
writeFile(
|
|
"app/safe/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile("app/safe/loading.tsx", "export default function Loading() { return null; }\n");
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
rewrites: () => ({
|
|
beforeFiles: [
|
|
{
|
|
source: "/rewrite-me",
|
|
destination: "/safe",
|
|
has: [{ type: "header", key: "x-route-variant", value: "1" }],
|
|
},
|
|
],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/safe"]);
|
|
expect(manifest?.excludedWarmPaths).toEqual(["/rewrite-me"]);
|
|
expect(manifest?.rscPaths).toEqual(["/safe"]);
|
|
expect(manifest?.loadingShellPaths).toEqual(["/safe"]);
|
|
});
|
|
|
|
it("warms rewrite source paths when routing runs in an uncached stage", async () => {
|
|
// Rewrite-aware prefetches can resolve a public URL to a different route:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/rewrite-me/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile("app/rewrite-me/loading.tsx", "export default function Loading() { return null; }\n");
|
|
writeFile(
|
|
"app/safe/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile("app/safe/loading.tsx", "export default function Loading() { return null; }\n");
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
rewrites: () => ({
|
|
beforeFiles: [
|
|
{
|
|
source: "/rewrite-me",
|
|
destination: "/safe",
|
|
has: [{ type: "header", key: "x-route-variant", value: "1" }],
|
|
},
|
|
],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
requestRouting: "uncached-stage",
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/rewrite-me", "/safe"]);
|
|
expect(manifest?.excludedWarmPaths).toBeUndefined();
|
|
expect(manifest?.rscPaths).toEqual(["/rewrite-me", "/safe"]);
|
|
expect(manifest?.loadingShellPaths).toEqual(["/rewrite-me", "/safe"]);
|
|
expect(manifest?.routePatterns?.["/rewrite-me"]?.cacheabilityProbe).toMatchObject({
|
|
routeMayResolve: true,
|
|
});
|
|
expect(manifest?.routePatterns?.["/safe"]?.cacheabilityProbe?.routeMayResolve).toBeUndefined();
|
|
});
|
|
|
|
it("retains external rewrite sources only when routing runs in an uncached stage", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/external/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile(
|
|
"app/safe/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
rewrites: () => ({
|
|
beforeFiles: [
|
|
{
|
|
source: "/external",
|
|
destination: "https://upstream.example/:path*",
|
|
has: [{ type: "header", key: "x-use-external", value: "1" }],
|
|
},
|
|
],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const singleStageManifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(singleStageManifest?.paths).toEqual(["/safe"]);
|
|
expect(singleStageManifest?.excludedWarmPaths).toEqual(["/external"]);
|
|
expect(singleStageManifest?.routePatterns?.["/external"]).toBeUndefined();
|
|
|
|
const stagedManifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
requestRouting: "uncached-stage",
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(stagedManifest?.paths).toEqual(["/external", "/safe"]);
|
|
expect(stagedManifest?.excludedWarmPaths).toBeUndefined();
|
|
expect(stagedManifest?.routePatterns?.["/external"]?.cacheabilityProbe).toMatchObject({
|
|
requestStageMayTerminate: true,
|
|
});
|
|
});
|
|
|
|
it.each(["afterFiles", "fallback"] as const)(
|
|
"keeps non-dynamic App routes ahead of %s rewrites",
|
|
async (phase) => {
|
|
// Next.js checks non-dynamic pages before afterFiles and every matched
|
|
// route before fallback:
|
|
// https://github.com/vercel/next.js/blob/canary/docs/01-app/03-api-reference/05-config/01-next-config-js/rewrites.mdx
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/local/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile(
|
|
"app/external/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile(
|
|
"app/target/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile(
|
|
"app/api/static/route.ts",
|
|
"export const revalidate = 60; export function GET() { return new Response('ok'); }\n",
|
|
);
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const rewrites = [
|
|
{ source: "/local", destination: "/target" },
|
|
{ source: "/external", destination: "https://upstream.example/local" },
|
|
{ source: "/api/static", destination: "https://upstream.example/api" },
|
|
];
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
rewrites: () => ({
|
|
beforeFiles: [],
|
|
afterFiles: phase === "afterFiles" ? rewrites : [],
|
|
fallback: phase === "fallback" ? rewrites : [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
requestRouting: "uncached-stage",
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toHaveLength(3);
|
|
expect(manifest?.paths).toEqual(expect.arrayContaining(["/external", "/local", "/target"]));
|
|
expect(manifest?.routeHandlerPaths).toEqual(["/api/static"]);
|
|
expect(manifest?.excludedWarmPaths).toBeUndefined();
|
|
expect(manifest?.routePatterns?.["/external"]).toBeDefined();
|
|
expect(
|
|
manifest?.routePatterns?.["/local"]?.cacheabilityProbe?.routeMayResolve,
|
|
).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it("still lets external afterFiles rewrites shadow discovered dynamic App paths", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
"export const revalidate = 60;",
|
|
"export function generateStaticParams() { return []; }",
|
|
"export default function Page() {}",
|
|
].join("\n"),
|
|
);
|
|
writeFile(
|
|
"app/target/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
rewrites: () => ({
|
|
beforeFiles: [],
|
|
afterFiles: [
|
|
{
|
|
source: "/cached/intro",
|
|
destination: "https://upstream.example/intro",
|
|
},
|
|
{ source: "/cached/featured", destination: "/target" },
|
|
],
|
|
fallback: [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
requestRouting: "uncached-stage",
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toHaveLength(3);
|
|
expect(manifest?.paths).toEqual(
|
|
expect.arrayContaining(["/cached/featured", "/cached/intro", "/target"]),
|
|
);
|
|
expect(manifest?.excludedWarmPaths).toBeUndefined();
|
|
expect(
|
|
manifest?.routePatterns?.["/cached/intro"]?.cacheabilityProbe?.requestStageMayTerminate,
|
|
).toBe(true);
|
|
expect(manifest?.routePatterns?.["/cached/featured"]?.cacheabilityProbe?.routeMayResolve).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("keeps a non-dynamic Pages route ahead of an external afterFiles rewrite", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile("pages/about.tsx", "export default function Page() {}\n");
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
rewrites: () => ({
|
|
beforeFiles: [],
|
|
afterFiles: [{ source: "/about", destination: "https://upstream.example/about" }],
|
|
fallback: [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
requestRouting: "uncached-stage",
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/about"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/about"]);
|
|
expect(manifest?.excludedWarmPaths).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
destination: "https://upstream.example/first",
|
|
expectedTerminal: true,
|
|
phase: "afterFiles" as const,
|
|
source: "/first",
|
|
},
|
|
{
|
|
destination: "https://upstream.example/:path*",
|
|
expectedTerminal: false,
|
|
phase: "fallback" as const,
|
|
source: "/:path*",
|
|
},
|
|
])("applies Pages dynamic-route precedence around $phase rewrites", async (testCase) => {
|
|
// The fallback case is ported from Next.js:
|
|
// test/e2e/fallback-false-rewrite/fallback-false-rewrite.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/fallback-false-rewrite/fallback-false-rewrite.test.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile(
|
|
"pages/[slug].tsx",
|
|
[
|
|
"export function getStaticPaths() { return { paths: [], fallback: false }; }",
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
|
"export default function Page() {}",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(
|
|
Response.json({ fallback: false, paths: ["/first", "/second"] }),
|
|
);
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const rewrite = {
|
|
source: testCase.source,
|
|
destination: testCase.destination,
|
|
};
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
rewrites: () => ({
|
|
beforeFiles: [],
|
|
afterFiles: testCase.phase === "afterFiles" ? [rewrite] : [],
|
|
fallback: testCase.phase === "fallback" ? [rewrite] : [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
requestRouting: "uncached-stage",
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/first", "/second"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/first", "/second"]);
|
|
expect(manifest?.excludedWarmPaths).toBeUndefined();
|
|
expect(
|
|
manifest?.routePatterns?.["/first"]?.cacheabilityProbe?.requestStageMayTerminate === true,
|
|
).toBe(testCase.expectedTerminal);
|
|
});
|
|
|
|
it("allows the uncached middleware stage to resolve warm routes", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("middleware.ts", "export default function middleware() {}\n");
|
|
writeFile(
|
|
"app/middleware-source/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig: await resolveNextConfig({}, tmpDir),
|
|
requestRouting: "uncached-stage",
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.routePatterns?.["/middleware-source"]?.cacheabilityProbe).toMatchObject({
|
|
canPrunePattern: true,
|
|
requestStageMayTerminate: true,
|
|
routeMayResolve: true,
|
|
});
|
|
});
|
|
|
|
it("retains redirect sources only when routing runs in an uncached stage", async () => {
|
|
// Next.js applies config redirects before rendering the filesystem route:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/redirect-me/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile(
|
|
"app/safe/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
redirects: () => [
|
|
{
|
|
source: "/redirect-me",
|
|
destination: "/safe",
|
|
has: [{ type: "header", key: "x-redirect", value: "1" }],
|
|
permanent: false,
|
|
},
|
|
],
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const singleStageManifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(singleStageManifest?.paths).toEqual(["/safe"]);
|
|
expect(singleStageManifest?.excludedWarmPaths).toEqual(["/redirect-me"]);
|
|
expect(singleStageManifest?.rscPaths).toEqual(["/safe"]);
|
|
|
|
const stagedManifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
requestRouting: "uncached-stage",
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(stagedManifest?.paths).toEqual(["/redirect-me", "/safe"]);
|
|
expect(stagedManifest?.excludedWarmPaths).toBeUndefined();
|
|
expect(stagedManifest?.rscPaths).toEqual(["/redirect-me", "/safe"]);
|
|
expect(stagedManifest?.routePatterns?.["/redirect-me"]?.cacheabilityProbe).toMatchObject({
|
|
requestStageMayTerminate: true,
|
|
});
|
|
});
|
|
|
|
it("discovers a static child route from parent-layout generateStaticParams", async () => {
|
|
// Next.js supports parent layouts generating params for static child pages:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-prefetch-static/app/[region]/(default)/layout.js
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/[category]/layout.tsx",
|
|
[
|
|
"export function generateStaticParams() { return [{ category: 'news' }]; }",
|
|
"export default function Layout({ children }) { return children; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile(
|
|
"app/[category]/foo/page.tsx",
|
|
"export const revalidate = 60; export default function Page() {}\n",
|
|
);
|
|
writeFile(
|
|
"app/[category]/foo/loading.tsx",
|
|
"export default function Loading() { return null; }\n",
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/news/foo"]);
|
|
expect(manifest?.rscPaths).toEqual(["/news/foo"]);
|
|
expect(manifest?.loadingShellPaths).toEqual(["/news/foo"]);
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"http://127.0.0.1:43210/__vinext/prerender/static-params?pattern=%2F%3Acategory",
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("discovers dynamic App MDX paths from the built runtime", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/[slug]/page.mdx",
|
|
'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n',
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }]));
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{ pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] },
|
|
tmpDir,
|
|
);
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
nextConfig,
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/hello"]);
|
|
expect(manifest?.rscPaths).toEqual(["/hello"]);
|
|
});
|
|
|
|
it("discovers dynamic Pages MDX paths from the built runtime", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile(
|
|
"pages/[slug].mdx",
|
|
[
|
|
'export function getStaticPaths() { return { paths: ["/hello"], fallback: false } }',
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 } }",
|
|
"",
|
|
"# Hello",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json({ fallback: false, paths: ["/hello"] }));
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{ pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] },
|
|
tmpDir,
|
|
);
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, nextConfig });
|
|
|
|
expect(manifest?.paths).toEqual(["/hello"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/hello"]);
|
|
});
|
|
|
|
it("only advertises HTML build identity when the CDN adapter guarantees it", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/page.tsx", "export default function Page() { return null; }\n");
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" });
|
|
|
|
expect(manifest?.buildIdentity).toBeUndefined();
|
|
expect(manifest?.rscBuildId).toBe("rsc-build-a");
|
|
});
|
|
|
|
it("matches rewrites against the trailing-slash warm URL", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/foo/page.tsx", "export default function Page() {}\n");
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
trailingSlash: true,
|
|
rewrites: () => ({
|
|
beforeFiles: [{ source: "/foo/", destination: "/other" }],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual([]);
|
|
expect(manifest?.rscPaths).toEqual([]);
|
|
expect(manifest?.excludedWarmPaths).toEqual(["/foo"]);
|
|
});
|
|
|
|
it("checks rewrite identity for every configured domain default locale", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/foo/page.tsx", "export default function Page() {}\n");
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
i18n: {
|
|
defaultLocale: "en",
|
|
locales: ["en", "fr"],
|
|
domains: [{ domain: "example.fr", defaultLocale: "fr" }],
|
|
},
|
|
rewrites: () => ({
|
|
beforeFiles: [{ source: "/fr/foo", destination: "/other", locale: false }],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual([]);
|
|
expect(manifest?.rscPaths).toEqual([]);
|
|
expect(manifest?.excludedWarmPaths).toEqual(["/foo"]);
|
|
});
|
|
|
|
it("excludes Pages-owned hybrid paths from App warm discovery", async () => {
|
|
// Next.js resolves matching Pages and App routes by cross-router specificity:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/pages-to-app-routing/pages-to-app-routing.test.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/[...path]/page.tsx",
|
|
[
|
|
"export function generateStaticParams() { return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile("app/[...path]/loading.tsx", "export default function Loading() { return null; }\n");
|
|
writeFile("app/pages-dir/static/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile("app/specific/[id]/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile(
|
|
"app/specific/[id]/loading.tsx",
|
|
"export default function Loading() { return null; }\n",
|
|
);
|
|
writeFile("pages/pages-dir/[dynamic].tsx", "export default function Page() { return null; }\n");
|
|
writeFile(
|
|
"pages/api/[slug].ts",
|
|
"export default function handler(_request, response) { response.end('ok'); }\n",
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/pages-dir/static", "/pages-dir/foobar", "/specific/value"]);
|
|
expect(manifest?.appPaths).toEqual(["/pages-dir/static", "/specific/value"]);
|
|
expect(manifest?.rscPaths).toEqual(["/pages-dir/static", "/specific/value"]);
|
|
expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/pages-dir/foobar"]);
|
|
});
|
|
|
|
it("retains empty generateStaticParams patterns for on-demand admission", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/fallback-prefetch
|
|
// https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/fallback-prefetch
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/posts/[slug]/page.tsx",
|
|
[
|
|
"export function generateStaticParams() { return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json([]));
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual([]);
|
|
expect(manifest?.fallbackRoutePatterns).toEqual([
|
|
{ kind: "app-page", pattern: "/posts/:slug" },
|
|
]);
|
|
});
|
|
|
|
it("rejects empty generateStaticParams for a Cache Components App Page", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/empty-generate-static-params
|
|
// https://github.com/vercel/next.js/tree/canary/test/e2e/app-dir/empty-generate-static-params
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("next.config.js", "export default { cacheComponents: true };\n");
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/posts/[slug]/page.tsx",
|
|
[
|
|
"export function generateStaticParams() { return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json([]));
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await expect(
|
|
emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }),
|
|
).rejects.toThrow(
|
|
"When using Cache Components, all `generateStaticParams` functions must return at least one result.",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"empty generateStaticParams",
|
|
[
|
|
"export function generateStaticParams() { return []; }",
|
|
"export function GET() { return Response.json({ ok: true }); }",
|
|
].join("\n"),
|
|
Response.json([]),
|
|
],
|
|
[
|
|
"empty generateStaticParams with Cache Components",
|
|
[
|
|
"export function generateStaticParams() { return []; }",
|
|
"export function GET() { return Response.json({ ok: true }); }",
|
|
].join("\n"),
|
|
Response.json([]),
|
|
true,
|
|
],
|
|
[
|
|
"force-static without generateStaticParams",
|
|
[
|
|
'export const dynamic = "force-static";',
|
|
"export function GET() { return Response.json({ ok: true }); }",
|
|
].join("\n"),
|
|
new Response(null, { status: 204 }),
|
|
false,
|
|
],
|
|
])(
|
|
"retains dynamic App Route Handler patterns with %s",
|
|
async (_name, source, response, cacheComponents = false) => {
|
|
// Ported from Next.js static App Route eligibility:
|
|
// packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
if (cacheComponents) {
|
|
writeFile("next.config.js", "export default { cacheComponents: true };\n");
|
|
}
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/api/layout.tsx", 'export const dynamic = "force-dynamic";\n');
|
|
writeFile("app/api/posts/[slug]/route.ts", source);
|
|
vi.mocked(fetch).mockResolvedValue(response);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" });
|
|
|
|
expect(manifest?.routeHandlerPaths).toBeUndefined();
|
|
expect(manifest?.fallbackRoutePatterns).toEqual([
|
|
{ kind: "app-route", pattern: "/api/posts/:slug" },
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[true, true],
|
|
["blocking", true],
|
|
[false, false],
|
|
] as const)(
|
|
"retains zero-path Pages fallback=%s eligibility as a pattern record",
|
|
async (fallback, shouldRetain) => {
|
|
// Ported from Next.js:
|
|
// test/e2e/prerender/pages/non-json/[p].js
|
|
// test/e2e/prerender/pages/non-json-blocking/[p].js
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile(
|
|
"pages/posts/[slug].tsx",
|
|
[
|
|
"export function getStaticPaths() { return { paths: [], fallback: false }; }",
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json({ fallback, paths: [] }));
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir });
|
|
|
|
expect(manifest?.paths).toEqual([]);
|
|
expect(manifest?.fallbackRoutePatterns).toEqual(
|
|
shouldRetain ? [{ kind: "pages-page", pattern: "/posts/:slug" }] : undefined,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("retains force-static patterns without generateStaticParams", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/posts/[slug]/page.tsx",
|
|
[
|
|
'export const dynamic = "force-static";',
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 }));
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" });
|
|
|
|
expect(manifest?.fallbackRoutePatterns).toEqual([
|
|
{ kind: "app-page", pattern: "/posts/:slug" },
|
|
]);
|
|
});
|
|
|
|
it("does not certify an empty static params pattern beneath force-dynamic config", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/posts/layout.tsx", 'export const dynamic = "force-dynamic";\n');
|
|
writeFile(
|
|
"app/posts/[slug]/page.tsx",
|
|
[
|
|
'export const dynamic = "force-static";',
|
|
"export function generateStaticParams() { return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json([]));
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" });
|
|
|
|
expect(manifest?.fallbackRoutePatterns).toBeUndefined();
|
|
});
|
|
|
|
it("does not certify empty static params with a force-dynamic parallel default", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/posts/[slug]/page.tsx",
|
|
[
|
|
'export const dynamic = "force-static";',
|
|
"export function generateStaticParams() { return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile(
|
|
"app/posts/@sidebar/default.tsx",
|
|
[
|
|
'export const dynamic = "force-dynamic";',
|
|
"export default function Sidebar() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json([]));
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" });
|
|
|
|
expect(manifest?.fallbackRoutePatterns).toBeUndefined();
|
|
});
|
|
|
|
it("does not treat revalidate without generateStaticParams as a static fallback", async () => {
|
|
// Next.js only treats a dynamic route without generateStaticParams as static
|
|
// when its effective dynamic config is `error` or `force-static`.
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/posts/[slug]/page.tsx",
|
|
["export const revalidate = 60;", "export default function Page() { return null; }"].join(
|
|
"\n",
|
|
),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 }));
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" });
|
|
|
|
expect(manifest?.fallbackRoutePatterns).toBeUndefined();
|
|
});
|
|
|
|
it("does not inherit force-static past an explicit child auto without generateStaticParams", async () => {
|
|
// Next.js uses the nested-most dynamic config on the main segment chain.
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/utils.ts#L1028
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/posts/layout.tsx", 'export const dynamic = "force-static";\n');
|
|
writeFile(
|
|
"app/posts/[slug]/page.tsx",
|
|
['export const dynamic = "auto";', "export default function Page() { return null; }"].join(
|
|
"\n",
|
|
),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 }));
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" });
|
|
|
|
expect(manifest?.fallbackRoutePatterns).toBeUndefined();
|
|
});
|
|
|
|
it("uses the runtime-best App route for App-only loading-shell discovery", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/[...path]/page.tsx",
|
|
[
|
|
"export function generateStaticParams() { return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile("app/pages-dir/static/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile("app/specific/[id]/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile("app/api/status/route.ts", "export function GET() { return new Response('ok'); }\n");
|
|
writeFile(
|
|
"app/specific/[id]/loading.tsx",
|
|
"export default function Loading() { return null; }\n",
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.rscPaths).toEqual([
|
|
"/pages-dir/static",
|
|
"/pages-dir/foobar",
|
|
"/specific/value",
|
|
]);
|
|
expect(manifest?.paths).toEqual(["/pages-dir/static", "/pages-dir/foobar", "/specific/value"]);
|
|
expect(manifest?.appPaths).toEqual([
|
|
"/pages-dir/static",
|
|
"/pages-dir/foobar",
|
|
"/specific/value",
|
|
]);
|
|
expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]);
|
|
});
|
|
|
|
it("normalizes Pages i18n prefixes before resolving hybrid RSC ownership", async () => {
|
|
// Next.js normalizes locale prefixes before route matching:
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/base-server.ts
|
|
// Locale-prefixed paths do not become Pages API requests after stripping:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-api-support/index.test.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/[locale]/about/page.tsx",
|
|
[
|
|
"export function generateStaticParams() { return [{ locale: 'fr' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile(
|
|
"app/[locale]/about/loading.tsx",
|
|
"export default function Loading() { return null; }\n",
|
|
);
|
|
writeFile(
|
|
"app/[locale]/api/status/page.tsx",
|
|
[
|
|
"export function generateStaticParams() { return [{ locale: 'fr' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile(
|
|
"app/[locale]/api/status/loading.tsx",
|
|
"export default function Loading() { return null; }\n",
|
|
);
|
|
writeFile("pages/about.tsx", "export default function Page() { return null; }\n");
|
|
writeFile(
|
|
"pages/api/status.ts",
|
|
"export default function handler(_request, response) { response.end('ok'); }\n",
|
|
);
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{ i18n: { defaultLocale: "en", locales: ["en", "fr"] } },
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/fr/api/status", "/fr/about", "/about"]);
|
|
expect(manifest?.appPaths).toEqual(["/fr/api/status"]);
|
|
expect(manifest?.rscPaths).toEqual(["/fr/api/status"]);
|
|
expect(manifest?.loadingShellPaths).toEqual(["/fr/api/status"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/fr/about", "/about"]);
|
|
});
|
|
|
|
it("resolves Pages-discovered warm paths to their runtime App owner", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/health/route.ts", "export function GET() { return new Response('ok'); }\n");
|
|
writeFile("app/specific/[id]/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile(
|
|
"pages/[...path].tsx",
|
|
[
|
|
"export function getStaticPaths() { return { paths: [], fallback: false }; }",
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockImplementation(async (input) => {
|
|
const rawUrl =
|
|
input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
|
const url = new URL(rawUrl);
|
|
if (url.pathname === "/__vinext/prerender/pages-static-paths") {
|
|
return Response.json({ fallback: false, paths: ["/health", "/specific/value"] });
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
});
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
responseVary: "verbatim",
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/specific/value"]);
|
|
expect(manifest?.appPaths).toEqual(["/specific/value"]);
|
|
expect(manifest?.rscPaths).toEqual(["/specific/value"]);
|
|
expect(manifest?.pagesPaths).toEqual([]);
|
|
expect(manifest?.pagesDataPaths).toEqual([]);
|
|
});
|
|
|
|
it("fails path discovery when generateStaticParams discovery aborts", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => {
|
|
const error = new Error("aborted");
|
|
error.name = "AbortError";
|
|
throw error;
|
|
}),
|
|
);
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
"export const revalidate = 60;",
|
|
"export function generateStaticParams() { return [{ slug: 'intro' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow(
|
|
"Failed to discover warmup path(s) for /cached/:slug: path discovery timed out after 30000ms",
|
|
);
|
|
});
|
|
|
|
it("explains how to move cloudflare:workers path discovery out of Node", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () =>
|
|
Response.json(
|
|
{
|
|
error: "Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Received protocol 'cloudflare:'",
|
|
},
|
|
{ status: 500 },
|
|
),
|
|
),
|
|
);
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
'import { env } from "cloudflare:workers";',
|
|
"export const revalidate = 60;",
|
|
"export async function generateStaticParams() { await env.KV.get('paths'); return []; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow(
|
|
"Cloudflare runtime bindings cannot execute in the local Node prerender server. Use `vinext-cloudflare deploy --experimental-warm-cdn-cache`",
|
|
);
|
|
});
|
|
|
|
it("passes a custom RSC bundle path to the path discovery server", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/custom-rsc/BUILD_ID", "build-a\n");
|
|
writeFile("dist/custom-rsc/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
"export const revalidate = 60;",
|
|
"export function generateStaticParams() { return [{ slug: 'intro' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const rscBundlePath = path.join(tmpDir, "dist/custom-rsc/index.js");
|
|
|
|
await emitPrerenderPathManifest({ root: tmpDir, rscBundlePath });
|
|
|
|
expect(startProdServerMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
outDir: toSlash(path.join(tmpDir, "dist")),
|
|
rscEntryPath: rscBundlePath,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses the generated application entry recorded in the server build manifest", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", 'import "cloudflare:workers";\n');
|
|
writeFile("dist/server/application-entry.js", "export default {};\n");
|
|
writeFile(
|
|
"dist/server/.vite/manifest.json",
|
|
JSON.stringify({
|
|
"virtual:vinext-rsc-entry": {
|
|
file: "application-entry.js",
|
|
isDynamicEntry: true,
|
|
},
|
|
}),
|
|
);
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
"export const revalidate = 60;",
|
|
"export function generateStaticParams() { return [{ slug: 'intro' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await emitPrerenderPathManifest({ root: tmpDir });
|
|
|
|
expect(startProdServerMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
rscEntryPath: toSlash(path.join(tmpDir, "dist/server/application-entry.js")),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("derives a custom RSC bundle path from route metadata", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/custom-rsc/BUILD_ID", "build-a\n");
|
|
writeFile("dist/custom-rsc/index.js", "export default {};\n");
|
|
writeFile(
|
|
"app/cached/[slug]/page.tsx",
|
|
[
|
|
"export const revalidate = 60;",
|
|
"export function generateStaticParams() { return [{ slug: 'intro' }]; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
routeRootConfig: { rscOutDir: "dist/custom-rsc" },
|
|
});
|
|
|
|
expect(startProdServerMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
outDir: toSlash(path.join(tmpDir, "dist")),
|
|
rscEntryPath: toSlash(path.join(tmpDir, "dist/custom-rsc/index.js")),
|
|
}),
|
|
);
|
|
expect(fs.existsSync(path.join(tmpDir, "dist/custom-rsc/vinext-prerender-paths.json"))).toBe(
|
|
false,
|
|
);
|
|
expect(fs.existsSync(path.join(tmpDir, "dist/server/vinext-prerender-paths.json"))).toBe(true);
|
|
});
|
|
|
|
it("loads next.config with the production build phase", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile(
|
|
"next.config.mjs",
|
|
"export default (phase) => ({ trailingSlash: phase === 'phase-production-build' });\n",
|
|
);
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/page.tsx", "export default function Page() { return null; }\n");
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir });
|
|
|
|
expect(manifest?.trailingSlash).toBe(true);
|
|
expect(manifest?.paths).toEqual(["/"]);
|
|
expect(startProdServerMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("honors a configured route root when discovering paths", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/wrong/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile("custom/app/right/page.tsx", "export default function Page() { return null; }\n");
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
routeRootConfig: { appDir: "custom" },
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/right"]);
|
|
});
|
|
|
|
it("honors disabled App Router when discovering paths", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/app-only/page.tsx", "export default function Page() { return null; }\n");
|
|
writeFile("pages/pages-only.tsx", "export default function Page() { return null; }\n");
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
root: tmpDir,
|
|
routeRootConfig: { disableAppRouter: true },
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/pages-only"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/pages-only"]);
|
|
expect(manifest?.routePatterns).toEqual({
|
|
"/pages-only": {
|
|
cacheabilityProbe: { canPrunePattern: true },
|
|
kind: "pages-page",
|
|
pattern: "/pages-only",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("excludes Pages API handlers from Pages-only concrete warm paths", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile(
|
|
"pages/[...slug].tsx",
|
|
[
|
|
"export function getStaticPaths() { return { paths: [], fallback: false }; }",
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
writeFile(
|
|
"pages/api/[...slug].ts",
|
|
"export default function handler(_request, response) { response.end('ok'); }\n",
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(
|
|
Response.json({ fallback: false, paths: ["/page/foo", "/api/foo"] }),
|
|
);
|
|
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir });
|
|
|
|
expect(manifest?.paths).toEqual(["/page/foo"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/page/foo"]);
|
|
});
|
|
|
|
it("discovers locale-specific Pages Router warmup keys", async () => {
|
|
// Next.js passes i18n metadata to getStaticPaths and qualifies each
|
|
// returned pathname with its selected locale:
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/static-paths/pages.ts
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile("pages/about.tsx", "export default function Page() { return null; }\n");
|
|
writeFile(
|
|
"pages/posts/[slug].tsx",
|
|
[
|
|
"export function getStaticPaths() { return { paths: [], fallback: false }; }",
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockImplementation(async (input) => {
|
|
const rawUrl =
|
|
input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
|
const url = new URL(rawUrl);
|
|
if (url.pathname === "/__vinext/prerender/pages-static-paths") {
|
|
return Response.json({
|
|
fallback: false,
|
|
paths: [
|
|
{ params: { slug: "hello" } },
|
|
{ params: { slug: "bonjour" }, locale: "fr" },
|
|
"/fr/posts/string-fr",
|
|
"/FR/posts/string-fr-upper",
|
|
"/en/posts/string-en-explicit",
|
|
"/posts/string-en",
|
|
"/posts/%7Euser/",
|
|
"/posts/a%2fb/",
|
|
],
|
|
});
|
|
}
|
|
return new Response(null, { status: 204 });
|
|
});
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }, { readPrerenderWarmPlan }] =
|
|
await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
import("../packages/cloudflare/src/cdn-warm.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
basePath: "/docs",
|
|
i18n: { defaultLocale: "en", locales: ["en", "fr"] },
|
|
trailingSlash: true,
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, nextConfig });
|
|
|
|
expect(manifest?.paths).toEqual([
|
|
"/about",
|
|
"/fr/about",
|
|
"/posts/hello",
|
|
"/fr/posts/bonjour",
|
|
"/fr/posts/string-fr",
|
|
"/FR/posts/string-fr-upper",
|
|
"/en/posts/string-en-explicit",
|
|
"/posts/string-en",
|
|
"/posts/%7Euser",
|
|
"/posts/a%2fb",
|
|
]);
|
|
expect(manifest?.pagesPaths).toEqual(manifest?.paths);
|
|
expect(manifest?.pagesDataPaths).toEqual([
|
|
"/docs/_next/data/build-a/en/posts/hello.json",
|
|
"/docs/_next/data/build-a/fr/posts/bonjour.json",
|
|
"/docs/_next/data/build-a/fr/posts/string-fr.json",
|
|
"/docs/_next/data/build-a/FR/posts/string-fr-upper.json",
|
|
"/docs/_next/data/build-a/en/posts/string-en-explicit.json",
|
|
"/docs/_next/data/build-a/en/posts/string-en.json",
|
|
"/docs/_next/data/build-a/en/posts/%7Euser.json",
|
|
"/docs/_next/data/build-a/en/posts/a%2fb.json",
|
|
]);
|
|
expect(
|
|
manifest?.routePatterns?.["/docs/_next/data/build-a/en/posts/hello.json"]?.cacheabilityProbe
|
|
?.concretePathname,
|
|
).toBe("/docs/posts/hello");
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"http://127.0.0.1:43210/__vinext/prerender/pages-static-paths?pattern=%2Fposts%2F%3Aslug&locales=%5B%22en%22%2C%22fr%22%5D&defaultLocale=en",
|
|
expect.any(Object),
|
|
);
|
|
expect(readPrerenderWarmPlan(tmpDir).paths).toEqual([
|
|
"/docs/about/",
|
|
"/docs/fr/about/",
|
|
"/docs/posts/hello/",
|
|
"/docs/fr/posts/bonjour/",
|
|
"/docs/fr/posts/string-fr/",
|
|
"/docs/FR/posts/string-fr-upper/",
|
|
"/docs/en/posts/string-en-explicit/",
|
|
"/docs/posts/string-en/",
|
|
"/docs/posts/%7Euser/",
|
|
"/docs/posts/a%2fb/",
|
|
]);
|
|
});
|
|
|
|
it("fails path discovery when getStaticPaths returns an HTTP error", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile(
|
|
"pages/posts/[slug].tsx",
|
|
[
|
|
"export function getStaticPaths() { throw new Error('boom'); }",
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(new Response("getStaticPaths exploded", { status: 500 }));
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow(
|
|
"Failed to discover warmup path(s) for /posts/:slug: path discovery returned HTTP 500",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["null", null, "Invalid value returned"],
|
|
["missing paths", { fallback: false }, "Invalid paths returned"],
|
|
["null paths", { fallback: false, paths: null }, "Invalid paths returned"],
|
|
["invalid fallback", { fallback: "yes", paths: [] }, "Invalid fallback"],
|
|
["extra key", { extra: true, fallback: false, paths: [] }, "Extra key(s)"],
|
|
])("fails path discovery for %s getStaticPaths results", async (_name, result, message) => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile(
|
|
"pages/posts/[slug].tsx",
|
|
[
|
|
"export function getStaticPaths() { return null; }",
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json(result));
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow(
|
|
`Failed to discover warmup path(s) for /posts/:slug: ${message}`,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["an extra entry key", "/posts/[slug].tsx", { extra: true, params: { slug: "x" } }],
|
|
["a numeric dynamic param", "/posts/[slug].tsx", { params: { slug: 123 } }],
|
|
["an array dynamic param", "/posts/[slug].tsx", { params: { slug: ["a", "b"] } }],
|
|
["a dot-segment dynamic param", "/posts/[slug].tsx", { params: { slug: "." } }],
|
|
["a scalar catch-all param", "/docs/[...parts].tsx", { params: { parts: "a" } }],
|
|
["a query-bearing string path", "/posts/[slug].tsx", "/posts/query?x=1"],
|
|
["a relative string path", "/posts/[slug].tsx", "posts/x"],
|
|
["a double-slash string path", "/posts/[slug].tsx", "/posts//x"],
|
|
["a raw backslash string path", "/posts/[slug].tsx", "/posts\\admin"],
|
|
["an encoded dot-segment string path", "/posts/[...slug].tsx", "/posts/%2E%2E/admin"],
|
|
["malformed percent-encoding", "/posts/[slug].tsx", "/posts/%ZZ/"],
|
|
])("fails path discovery for getStaticPaths entry with %s", async (_name, file, entry) => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile(
|
|
`pages${file}`,
|
|
[
|
|
"export function getStaticPaths() { return { paths: [], fallback: false }; }",
|
|
"export function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json({ fallback: false, paths: [entry] }));
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow(
|
|
"Failed to discover warmup path(s)",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["a numeric dynamic param", "app/posts/[slug]/page.tsx", [{ slug: 123 }]],
|
|
["a dot-segment dynamic param", "app/posts/[slug]/page.tsx", [{ slug: ".." }]],
|
|
["a scalar catch-all param", "app/docs/[...parts]/page.tsx", [{ parts: "a" }]],
|
|
["a missing optional catch-all", "app/docs/[[...parts]]/page.tsx", [{}]],
|
|
])("fails App path discovery for generateStaticParams with %s", async (_name, file, result) => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile(
|
|
file,
|
|
[
|
|
"export function generateStaticParams() { return []; }",
|
|
"export const revalidate = 60;",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
vi.mocked(fetch).mockResolvedValue(Response.json(result));
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow(
|
|
"Failed to discover warmup path(s)",
|
|
);
|
|
});
|
|
|
|
it("excludes only the locale-specific Pages key affected by a rewrite", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile("pages/about.tsx", "export default function Page() { return null; }\n");
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig(
|
|
{
|
|
i18n: { defaultLocale: "en", locales: ["en", "fr"] },
|
|
rewrites: () => ({
|
|
beforeFiles: [{ source: "/fr/about", destination: "/other", locale: false }],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
}),
|
|
},
|
|
tmpDir,
|
|
);
|
|
|
|
const manifest = await emitPrerenderPathManifest({
|
|
nextConfig,
|
|
responseVary: "verbatim",
|
|
root: tmpDir,
|
|
});
|
|
|
|
expect(manifest?.paths).toEqual(["/about"]);
|
|
expect(manifest?.pagesPaths).toEqual(["/about"]);
|
|
expect(manifest?.excludedWarmPaths).toEqual(["/fr/about"]);
|
|
});
|
|
|
|
it("discovers concrete getServerSideProps HTML and data identities", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/entry.js", "export default {};\n");
|
|
writeFile(
|
|
"pages/gssp.tsx",
|
|
[
|
|
"export async function getServerSideProps() { return { props: {} }; }",
|
|
"export default function Page() { return null; }",
|
|
].join("\n"),
|
|
);
|
|
const { emitPrerenderPathManifest } =
|
|
await import("../packages/vinext/src/build/prerender-paths.js");
|
|
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir });
|
|
|
|
expect(manifest?.pagesPaths).toEqual(["/gssp"]);
|
|
expect(manifest?.pagesDataPaths).toEqual(["/_next/data/build-a/gssp.json"]);
|
|
expect(manifest?.routePatterns).toEqual({
|
|
"/_next/data/build-a/gssp.json": {
|
|
cacheabilityProbe: { canPrunePattern: true, concretePathname: "/gssp" },
|
|
kind: "pages-page",
|
|
pattern: "/gssp",
|
|
},
|
|
"/gssp": {
|
|
cacheabilityProbe: { canPrunePattern: true },
|
|
kind: "pages-page",
|
|
pattern: "/gssp",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not reload disk config when supplied resolved config", async () => {
|
|
writeFile("package.json", JSON.stringify({ type: "module" }));
|
|
writeFile("next.config.mjs", 'throw new Error("disk config loaded unexpectedly");\n');
|
|
writeFile("dist/server/BUILD_ID", "build-a\n");
|
|
writeFile("dist/server/index.js", "export default {};\n");
|
|
writeFile("app/page.tsx", "export default function Page() { return null; }\n");
|
|
|
|
const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([
|
|
import("../packages/vinext/src/build/prerender-paths.js"),
|
|
import("../packages/vinext/src/config/next-config.js"),
|
|
]);
|
|
const nextConfig = await resolveNextConfig({ trailingSlash: true }, tmpDir);
|
|
|
|
const manifest = await emitPrerenderPathManifest({ root: tmpDir, nextConfig });
|
|
|
|
expect(manifest?.trailingSlash).toBe(true);
|
|
expect(manifest?.paths).toEqual(["/"]);
|
|
});
|
|
});
|