Files
cloudflare__vinext/tests/seed-cache.test.ts
T
Nathan Nguyen 0046e0691a fix(app-router): preserve encoded prerender params (#1597)
* fix(app-router): preserve encoded prerender params

App Router prerendered pages with encoded dynamic params could be bypassed in production and rendered again with decoded route params. This diverged from Next.js for generateStaticParams values like "sticks & stones".

The root cause was split across build and startup. The prerender render path only had the encoded URL, so normal route matching decoded the page params. After preserving encoded params for the render, production startup still seeded the ISR cache under the encoded artifact path while live requests lookup cache entries by the runtime-normalized pathname.

Carry trusted prerender params through the prerender-only request path, keep decoded params for dynamicParams validation, and seed prerendered App Router artifacts under the normalized runtime cache key while reading files from their encoded output path.

* chore(app-router): clean up prerender header internals

Reuse the shared record guard for prerender route param parsing and remove the local duplicate.

Keep Next.js INTERNAL_HEADERS as the exact upstream list, with vinext-only internal headers tracked separately but stripped by the same request pipeline. This fixes the CI unit failure while preserving the prerender header trust boundary.

Factor repeated Node-header conversion and forwarded-protocol handling in the production server to avoid local assertions and duplicate loops.

* fix(app-router): scope prerender params to route pattern

Bind the internal prerender route-param header to the route pattern that produced it so encoded render params cannot cross a rewrite boundary into a different dynamic route shape.

The App Router handler now only applies encoded prerender params when the payload routePattern matches the final matched route. Otherwise it falls back to the decoded params from normal route matching.

Tighten the header parser to validate the payload shape with Object.entries/Object.keys and add a regression for a prerendered source route rewritten to a dynamic target route with a different param name.

* fix(app-router): verify prerender params match route

Require trusted prerender route params to prove both the final route pattern and the final decoded matched params before using their encoded render values.

This prevents same-pattern rewrites from carrying stale prerender params into a different concrete route match, while keeping valid prerender renders encoded and static validation decoded.

Add handler regression coverage for same-pattern rewrites and helper coverage for catch-all array comparison.

* docs(app-router): clarify prerender route-param trust boundary

The prerender route-param readers and serializer carried implicit
coupling that read as security verification but actually delegated it.
A reader new to readTrustedPrerenderRouteParams could assume it verifies
the prerender secret, when verification happens once upstream at
prod-server's nodeToWebRequest boundary and every downstream read trusts
that. The double read-then-reattach in createAppRscHandler also depends
on the secret header surviving filterInternalHeaders, with no note of
that coupling.

Document the trust contract: secret is verified at the outer boundary,
downstream reads operate on already-trusted requests gated by
VINEXT_PRERENDER=1, and the secret header must stay out of the internal
strip list for the second read to succeed. Also document the
empty-params to null serialization contract.

No behavior change; comments only.
2026-05-27 10:55:26 +01:00

619 lines
20 KiB
TypeScript

/**
* Tests for seeding the memory cache from pre-rendered routes.
*
* Verifies that seedMemoryCacheFromPrerender() reads vinext-prerender.json
* and the corresponding HTML/RSC files from disk, then populates the
* CacheHandler so pre-rendered pages are served as cache HITs on first request.
*/
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import {
MemoryCacheHandler,
setCacheHandler,
getCacheHandler,
type CacheHandler,
type CacheHandlerValue,
type IncrementalCacheValue,
} from "../packages/vinext/src/shims/cache.js";
import { isrCacheKey, getRevalidateDuration } from "../packages/vinext/src/server/isr-cache.js";
import { seedMemoryCacheFromPrerender } from "../packages/vinext/src/server/seed-cache.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function createTempServerDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-seed-cache-"));
}
/**
* Write a vinext-prerender.json manifest and corresponding pre-rendered files
* to a temporary directory structure matching the production build layout.
*/
function setupPrerenderFixture(
serverDir: string,
manifest: { buildId: string; trailingSlash?: boolean; routes: unknown[] },
files: Record<string, string>,
): void {
fs.writeFileSync(
path.join(serverDir, "vinext-prerender.json"),
JSON.stringify(manifest, null, 2),
"utf-8",
);
const prerenderDir = path.join(serverDir, "prerendered-routes");
for (const [filePath, content] of Object.entries(files)) {
const fullPath = path.join(prerenderDir, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, "utf-8");
}
}
/**
* Write raw content to vinext-prerender.json (for corrupt manifest tests).
*/
function writeRawManifest(serverDir: string, content: string): void {
fs.writeFileSync(path.join(serverDir, "vinext-prerender.json"), content, "utf-8");
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("seedMemoryCacheFromPrerender", () => {
let serverDir: string;
beforeEach(() => {
serverDir = createTempServerDir();
setCacheHandler(new MemoryCacheHandler());
});
afterEach(() => {
fs.rmSync(serverDir, { recursive: true, force: true });
});
// ── App Router ISR routes ─────────────────────────────────────────────────
it("seeds App Router ISR routes with HTML and RSC entries", async () => {
const buildId = "test-build-001";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [{ route: "/about", status: "rendered", revalidate: 60, router: "app" }],
},
{
"about.html": "<html><body>About page</body></html>",
"about.rsc": "RSC payload for about",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const htmlKey = isrCacheKey("app", "/about", buildId) + ":html";
const htmlEntry = await getCacheHandler().get(htmlKey);
expect(htmlEntry).not.toBeNull();
const htmlValue = htmlEntry?.value;
expect(htmlValue).not.toBeNull();
expect(htmlValue?.kind).toBe("APP_PAGE");
if (htmlValue?.kind === "APP_PAGE") {
expect(htmlValue.html).toBe("<html><body>About page</body></html>");
}
const rscKey = isrCacheKey("app", "/about", buildId) + ":rsc";
const rscEntry = await getCacheHandler().get(rscKey);
expect(rscEntry).not.toBeNull();
const rscValue = rscEntry?.value;
expect(rscValue).not.toBeNull();
expect(rscValue?.kind).toBe("APP_PAGE");
if (rscValue?.kind === "APP_PAGE") {
expect(rscValue.rscData).toBeDefined();
const rscText = new TextDecoder().decode(rscValue.rscData!);
expect(rscText).toBe("RSC payload for about");
}
});
it("seeds the index route correctly", async () => {
const buildId = "test-build-002";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [{ route: "/", status: "rendered", revalidate: 30, router: "app" }],
},
{
"index.html": "<html><body>Home</body></html>",
"index.rsc": "RSC payload for index",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const htmlKey = isrCacheKey("app", "/", buildId) + ":html";
const htmlEntry = await getCacheHandler().get(htmlKey);
expect(htmlEntry).not.toBeNull();
expect(htmlEntry?.value?.kind).toBe("APP_PAGE");
});
it("seeds dynamic routes using their concrete path", async () => {
const buildId = "test-build-003";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [
{
route: "/blog/:slug",
status: "rendered",
revalidate: 120,
path: "/blog/hello-world",
router: "app",
},
],
},
{
"blog/hello-world.html": "<html><body>Blog post</body></html>",
"blog/hello-world.rsc": "RSC blog payload",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const htmlKey = isrCacheKey("app", "/blog/hello-world", buildId) + ":html";
const htmlEntry = await getCacheHandler().get(htmlKey);
expect(htmlEntry).not.toBeNull();
expect(htmlEntry?.value?.kind).toBe("APP_PAGE");
});
it("seeds encoded dynamic App Router paths under the runtime-normalized cache key", async () => {
const buildId = "encoded-path-test";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [
{
route: "/:id",
status: "rendered",
revalidate: false,
path: "/sticks%20%26%20stones",
router: "app",
},
],
},
{
"sticks%20%26%20stones.html":
"<html><body>params.id is sticks%20%26%20stones</body></html>",
"sticks%20%26%20stones.rsc": "RSC encoded payload",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const htmlKey = isrCacheKey("app", "/sticks & stones", buildId) + ":html";
const htmlEntry = await getCacheHandler().get(htmlKey);
expect(htmlEntry).not.toBeNull();
if (htmlEntry?.value?.kind === "APP_PAGE") {
expect(htmlEntry.value.html).toBe(
"<html><body>params.id is sticks%20%26%20stones</body></html>",
);
}
const staleEncodedKey = isrCacheKey("app", "/sticks%20%26%20stones", buildId) + ":html";
expect(await getCacheHandler().get(staleEncodedKey)).toBeNull();
});
// ── Return value ──────────────────────────────────────────────────────────
it("returns the number of seeded routes", async () => {
setupPrerenderFixture(
serverDir,
{
buildId: "count-test",
routes: [
{ route: "/a", status: "rendered", revalidate: 60, router: "app" },
{ route: "/b", status: "rendered", revalidate: 60, router: "app" },
{ route: "/c", status: "skipped", reason: "ssr" },
],
},
{
"a.html": "<html>A</html>",
"a.rsc": "RSC a",
"b.html": "<html>B</html>",
"b.rsc": "RSC b",
},
);
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(2);
});
it("returns 0 when no manifest exists", async () => {
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(0);
});
// ── Revalidate duration tracking ──────────────────────────────────────────
it("populates revalidate duration map for ISR routes", async () => {
const buildId = "reval-duration-test";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [{ route: "/isr", status: "rendered", revalidate: 45, router: "app" }],
},
{
"isr.html": "<html>ISR</html>",
"isr.rsc": "RSC isr",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const baseKey = isrCacheKey("app", "/isr", buildId);
expect(getRevalidateDuration(baseKey + ":html")).toBe(45);
expect(getRevalidateDuration(baseKey + ":rsc")).toBe(45);
});
it("preserves legacy revalidate context while writing cache-control metadata", async () => {
const contexts: Record<string, unknown>[] = [];
const handler: CacheHandler = {
async get(): Promise<CacheHandlerValue | null> {
return null;
},
async set(
_key: string,
_data: IncrementalCacheValue | null,
ctx?: Record<string, unknown>,
): Promise<void> {
contexts.push(ctx ?? {});
},
async revalidateTag(): Promise<void> {
// not used by this test
},
};
setCacheHandler(handler);
setupPrerenderFixture(
serverDir,
{
buildId: "seed-context-test",
routes: [{ route: "/isr", status: "rendered", revalidate: 60, expire: 300, router: "app" }],
},
{
"isr.html": "<html>ISR</html>",
"isr.rsc": "RSC isr",
},
);
await seedMemoryCacheFromPrerender(serverDir);
expect(contexts).toEqual([
{ cacheControl: { revalidate: 60, expire: 300 }, revalidate: 60 },
{ cacheControl: { revalidate: 60, expire: 300 }, revalidate: 60 },
]);
});
it("can write through an injected app page cache writer", async () => {
const writes: {
key: string;
metadata: { expireSeconds?: number; revalidateSeconds?: number };
valueKind: string;
}[] = [];
setupPrerenderFixture(
serverDir,
{
buildId: "seed-injected-writer-test",
routes: [{ route: "/isr", status: "rendered", revalidate: 60, expire: 300, router: "app" }],
},
{
"isr.html": "<html>ISR</html>",
"isr.rsc": "RSC isr",
},
);
await seedMemoryCacheFromPrerender(serverDir, {
async writeAppPageEntry(key, data, metadata): Promise<void> {
writes.push({ key, metadata, valueKind: data.kind });
},
});
const baseKey = isrCacheKey("app", "/isr", "seed-injected-writer-test");
expect(writes).toEqual([
{
key: baseKey + ":html",
metadata: { expireSeconds: 300, revalidateSeconds: 60 },
valueKind: "APP_PAGE",
},
{
key: baseKey + ":rsc",
metadata: { expireSeconds: 300, revalidateSeconds: 60 },
valueKind: "APP_PAGE",
},
]);
});
it("can use injected runtime app page cache key builders", async () => {
const keys: string[] = [];
setupPrerenderFixture(
serverDir,
{
buildId: "manifest-build-id",
routes: [{ route: "/isr", status: "rendered", revalidate: 60, router: "app" }],
},
{
"isr.html": "<html>ISR</html>",
"isr.rsc": "RSC isr",
},
);
await seedMemoryCacheFromPrerender(serverDir, {
buildAppPageHtmlKey(pathname) {
return `runtime-build:${pathname}:html`;
},
buildAppPageRscKey(pathname) {
return `runtime-build:${pathname}:rsc`;
},
async writeAppPageEntry(key): Promise<void> {
keys.push(key);
},
});
expect(keys).toEqual(["runtime-build:/isr:html", "runtime-build:/isr:rsc"]);
});
it("does not set revalidate duration for static routes", async () => {
const buildId = "static-duration-test";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [{ route: "/static", status: "rendered", revalidate: false, router: "app" }],
},
{
"static.html": "<html>Static</html>",
"static.rsc": "RSC static",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const baseKey = isrCacheKey("app", "/static", buildId);
expect(getRevalidateDuration(baseKey + ":html")).toBeUndefined();
expect(getRevalidateDuration(baseKey + ":rsc")).toBeUndefined();
});
// ── Static routes (revalidate: false) ─────────────────────────────────────
it("seeds static routes (revalidate: false) with no expiry", async () => {
const buildId = "test-build-004";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [{ route: "/static", status: "rendered", revalidate: false, router: "app" }],
},
{
"static.html": "<html><body>Static page</body></html>",
"static.rsc": "RSC static payload",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const htmlKey = isrCacheKey("app", "/static", buildId) + ":html";
const htmlEntry = await getCacheHandler().get(htmlKey);
expect(htmlEntry).not.toBeNull();
expect(htmlEntry?.cacheState).toBeUndefined();
});
// ── Skipped and errored routes ────────────────────────────────────────────
it("does not seed skipped routes", async () => {
const buildId = "test-build-005";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [
{ route: "/ssr-page", status: "skipped", reason: "ssr" },
{ route: "/about", status: "rendered", revalidate: 60, router: "app" },
],
},
{
"about.html": "<html><body>About</body></html>",
"about.rsc": "RSC about",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const skippedKey = isrCacheKey("app", "/ssr-page", buildId) + ":html";
expect(await getCacheHandler().get(skippedKey)).toBeNull();
const aboutKey = isrCacheKey("app", "/about", buildId) + ":html";
expect(await getCacheHandler().get(aboutKey)).not.toBeNull();
});
it("does not seed errored routes", async () => {
const buildId = "test-build-006";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [{ route: "/broken", status: "error", error: "render failed" }],
},
{},
);
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(0);
});
// ── Multiple routes ───────────────────────────────────────────────────────
it("seeds multiple routes in one pass", async () => {
const buildId = "test-build-007";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [
{ route: "/", status: "rendered", revalidate: 30, router: "app" },
{ route: "/about", status: "rendered", revalidate: 60, router: "app" },
{
route: "/blog/:slug",
status: "rendered",
revalidate: 120,
path: "/blog/post-1",
router: "app",
},
],
},
{
"index.html": "<html>Home</html>",
"index.rsc": "RSC home",
"about.html": "<html>About</html>",
"about.rsc": "RSC about",
"blog/post-1.html": "<html>Blog Post 1</html>",
"blog/post-1.rsc": "RSC blog 1",
},
);
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(3);
for (const pathname of ["/", "/about", "/blog/post-1"]) {
const htmlKey = isrCacheKey("app", pathname, buildId) + ":html";
expect(
await getCacheHandler().get(htmlKey),
`expected cache entry for ${pathname}`,
).not.toBeNull();
}
});
// ── Graceful degradation ──────────────────────────────────────────────────
it("is a no-op when vinext-prerender.json does not exist", async () => {
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(0);
});
it("skips routes whose HTML files are missing from disk", async () => {
const buildId = "test-build-008";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [{ route: "/missing", status: "rendered", revalidate: 60, router: "app" }],
},
{},
);
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(0);
});
it("returns 0 and warns on corrupt manifest JSON", async () => {
writeRawManifest(serverDir, "{ this is not valid json !!!");
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(0);
});
it("returns 0 when manifest has no buildId", async () => {
writeRawManifest(serverDir, JSON.stringify({ routes: [] }));
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(0);
});
// ── trailingSlash ──────────────────────────────────────────────────────────
it("reads files from trailingSlash directory layout", async () => {
const buildId = "test-build-010";
setupPrerenderFixture(
serverDir,
{
buildId,
trailingSlash: true,
routes: [{ route: "/about", status: "rendered", revalidate: 60, router: "app" }],
},
{
"about/index.html": "<html><body>About (trailing slash)</body></html>",
"about.rsc": "RSC about",
},
);
await seedMemoryCacheFromPrerender(serverDir);
const htmlKey = isrCacheKey("app", "/about", buildId) + ":html";
const htmlEntry = await getCacheHandler().get(htmlKey);
expect(htmlEntry).not.toBeNull();
if (htmlEntry?.value?.kind === "APP_PAGE") {
expect(htmlEntry.value.html).toBe("<html><body>About (trailing slash)</body></html>");
}
});
// ── RSC file optional ─────────────────────────────────────────────────────
it("seeds HTML even when RSC file is missing", async () => {
const buildId = "test-build-009";
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [{ route: "/html-only", status: "rendered", revalidate: 60, router: "app" }],
},
{
"html-only.html": "<html><body>HTML only</body></html>",
},
);
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(1);
const htmlKey = isrCacheKey("app", "/html-only", buildId) + ":html";
expect(await getCacheHandler().get(htmlKey)).not.toBeNull();
const rscKey = isrCacheKey("app", "/html-only", buildId) + ":rsc";
expect(await getCacheHandler().get(rscKey)).toBeNull();
});
// ── Long pathnames (FNV hash path) ────────────────────────────────────────
it("seeds routes with very long pathnames that hit the hash path", async () => {
const buildId = "hash-test";
const longSlug = "a".repeat(200);
const longPath = `/blog/${longSlug}`;
setupPrerenderFixture(
serverDir,
{
buildId,
routes: [
{
route: "/blog/:slug",
status: "rendered",
revalidate: 60,
path: longPath,
router: "app",
},
],
},
{
[`blog/${longSlug}.html`]: "<html>Long path</html>",
[`blog/${longSlug}.rsc`]: "RSC long",
},
);
const count = await seedMemoryCacheFromPrerender(serverDir);
expect(count).toBe(1);
// Verify the hashed key matches what isrCacheKey produces
const htmlKey = isrCacheKey("app", longPath, buildId) + ":html";
expect(htmlKey).toContain("__hash:");
expect(await getCacheHandler().get(htmlKey)).not.toBeNull();
});
});