Files
cloudflare__vinext/tests/app-page-response.test.ts
T
Nathan Nguyen 9d5d1ce50d fix(app-router): validate RSC cache-busting params (#991)
* fix(app-router): validate RSC cache-busting params

App Router RSC responses could vary by request headers while client fetches and server validation still relied on Vary-aware caches. CDNs that key only by URL could reuse a mounted-slot or interception-context RSC payload for a different request.

Next.js validates a cache-busting _rsc search param against the RSC request headers and redirects malformed requests to the canonical URL. Vinext now applies the same invariant to its .rsc request model, centralizes the RSC Vary header set in normal server modules, and de-duplicates middleware Vary additions.

Tests cover missing and stale _rsc values, variant-header hashes, generated-entry delegation, browser RSC request markers, response Vary propagation, and App Router RSC integration.

* fix(app-router): hide RSC cache tokens from navigation URLs

* fix(app-router): hide RSC cache token from searchParams

* fix(app-router): address RSC cache-busting review

* fix(app-router): preserve HTML redirect destination for direct .rsc navigations

Config redirects from next.config.js were unconditionally converted to
.rsc destinations for any request whose URL ended in .rsc. This broke
direct browser navigations to .rsc URLs (e.g. a bookmark or external
link) because the browser was redirected to an RSC payload it could not
render.

Client-side RSC fetches always send the RSC: 1 header, so restrict the
.rsc destination conversion to requests that carry that header. Direct
browser hits without the header continue to redirect to the canonical
HTML URL, matching the pre-PR behaviour for config redirects.
2026-05-04 18:55:43 +01:00

466 lines
15 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
buildAppPageHtmlResponse,
buildAppPageRscResponse,
mergeMiddlewareResponseHeaders,
resolveAppPageHtmlResponsePolicy,
resolveAppPageRscResponsePolicy,
} from "../packages/vinext/src/server/app-page-response.js";
import { VINEXT_RSC_VARY_HEADER } from "../packages/vinext/src/server/app-rsc-cache-busting.js";
function createBody(text: string): ReadableStream {
return new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
controller.close();
},
});
}
describe("app page response helpers", () => {
it("resolves RSC response policy for static and ISR responses", () => {
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: true,
isProduction: true,
revalidateSeconds: null,
}),
).toEqual({
cacheControl: "s-maxage=31536000, stale-while-revalidate",
cacheState: "STATIC",
});
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: false,
expireSeconds: 300,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "s-maxage=60, stale-while-revalidate=240",
cacheState: "MISS",
});
});
it("resolves RSC response policy for force-dynamic, infinity, and default cases", () => {
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: false,
isDynamicError: false,
isForceDynamic: true,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
});
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: Infinity,
}),
).toEqual({
cacheControl: "s-maxage=31536000, stale-while-revalidate",
cacheState: "STATIC",
});
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: null,
}),
).toEqual({});
});
it("resolves RSC response policy as no-store when dynamic usage is detected during build", () => {
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: true,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
});
});
it("resolves HTML response policy precedence", () => {
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: true,
hasScriptNonce: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
shouldWriteToCache: false,
});
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: false,
hasScriptNonce: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: false,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "s-maxage=60, stale-while-revalidate",
cacheState: undefined,
shouldWriteToCache: false,
});
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: false,
hasScriptNonce: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: Infinity,
}),
).toEqual({
cacheControl: "s-maxage=31536000, stale-while-revalidate",
cacheState: "STATIC",
shouldWriteToCache: false,
});
});
it("resolves HTML response policy when cache writes stay enabled", () => {
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: false,
hasScriptNonce: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "s-maxage=60, stale-while-revalidate",
cacheState: "MISS",
shouldWriteToCache: true,
});
});
it("treats revalidate = 0 as no-store in RSC response policy", () => {
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 0,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
});
// revalidate = 0 takes priority over isForceStatic
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: true,
isProduction: true,
revalidateSeconds: 0,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
});
});
it("treats revalidate = 0 as no-store in HTML response policy", () => {
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: false,
hasScriptNonce: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 0,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
shouldWriteToCache: false,
});
// revalidate = 0 takes priority over isForceStatic
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: false,
hasScriptNonce: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: true,
isProduction: true,
revalidateSeconds: 0,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
shouldWriteToCache: false,
});
});
it("treats force-static with explicit revalidate as ISR in both policy helpers", () => {
expect(
resolveAppPageRscResponsePolicy({
dynamicUsedDuringBuild: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: true,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "s-maxage=60, stale-while-revalidate",
cacheState: "MISS",
});
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: false,
hasScriptNonce: false,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: true,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "s-maxage=60, stale-while-revalidate",
cacheState: "MISS",
shouldWriteToCache: true,
});
});
it("treats HTML responses with a script nonce as no-store", () => {
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: false,
hasScriptNonce: true,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
shouldWriteToCache: false,
});
});
it("builds RSC responses with params, middleware headers, and timing", async () => {
const middlewareHeaders = new Headers();
middlewareHeaders.set("cache-control", "private, max-age=5");
middlewareHeaders.append("set-cookie", "session=abc; Path=/");
middlewareHeaders.append("vary", "Next-Router-State-Tree");
const response = buildAppPageRscResponse(createBody("flight"), {
middlewareContext: {
headers: middlewareHeaders,
status: 202,
},
params: { slug: "test" },
policy: {
cacheControl: "s-maxage=60, stale-while-revalidate",
cacheState: "MISS",
},
timing: {
compileEnd: 15,
handlerStart: 10,
responseKind: "rsc",
},
});
expect(response.status).toBe(202);
expect(response.headers.get("content-type")).toBe("text/x-component; charset=utf-8");
expect(response.headers.get("x-vinext-params")).toBe(encodeURIComponent('{"slug":"test"}'));
expect(response.headers.get("cache-control")).toBe("private, max-age=5");
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
expect(response.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER);
expect(response.headers.get("x-vinext-timing")).toBe("10,5,-1");
await expect(response.text()).resolves.toBe("flight");
});
it("percent-encodes X-Vinext-Params so non-ASCII characters survive the ByteString header constraint (issue #676)", () => {
// HTTP headers are ByteStrings: each character value must be <= 255.
// JSON.stringify preserves non-ASCII characters verbatim (e.g. Korean 완 = U+C644 = 50756),
// which causes Headers.set() to throw a TypeError in compliant runtimes.
// The fix: encodeURIComponent the JSON before setting the header.
const koreanSlug = "useState-완전정복";
const response = buildAppPageRscResponse(createBody("flight"), {
middlewareContext: { headers: new Headers(), status: 200 },
params: { slug: [koreanSlug] },
policy: {},
timing: { handlerStart: 0, responseKind: "rsc" },
});
const rawHeader = response.headers.get("x-vinext-params")!;
// Header value must be ASCII-safe (all byte values <= 127 after encoding)
expect(Array.from(rawHeader).every((c) => c.charCodeAt(0) <= 127)).toBe(true);
// Decoding must round-trip back to the original params
expect(JSON.parse(decodeURIComponent(rawHeader))).toEqual({ slug: [koreanSlug] });
});
it("builds HTML responses with middleware override/append header semantics", async () => {
const middlewareHeaders = new Headers();
middlewareHeaders.set("cache-control", "private, max-age=5");
middlewareHeaders.append("set-cookie", "mw=1; Path=/");
middlewareHeaders.set("vary", "Next-Router-State-Tree");
middlewareHeaders.append("x-extra", "present");
middlewareHeaders.set("cache-control", "private, max-age=5");
const response = buildAppPageHtmlResponse(createBody("<h1>page</h1>"), {
draftCookie: "__prerender_bypass=token; Path=/",
fontLinkHeader: "</font.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
middlewareContext: {
headers: middlewareHeaders,
status: 203,
},
policy: {
cacheControl: "s-maxage=31536000, stale-while-revalidate",
cacheState: "STATIC",
},
timing: {
compileEnd: 12,
handlerStart: 10,
renderEnd: 20,
responseKind: "html",
},
});
expect(response.status).toBe(203);
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("cache-control")).toBe("private, max-age=5");
expect(response.headers.get("x-vinext-cache")).toBe("STATIC");
expect(response.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER);
expect(response.headers.get("link")).toBe(
"</font.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
);
expect(response.headers.get("x-extra")).toBe("present");
expect(response.headers.get("x-vinext-timing")).toBe("10,2,8");
const setCookies = response.headers.getSetCookie();
expect(setCookies).toContain("__prerender_bypass=token; Path=/");
expect(setCookies).toContain("mw=1; Path=/");
await expect(response.text()).resolves.toBe("<h1>page</h1>");
});
});
describe("mergeMiddlewareResponseHeaders", () => {
it("is a no-op when middleware headers are null", () => {
const target = new Headers({ "Content-Type": "text/plain" });
mergeMiddlewareResponseHeaders(target, null);
expect(target.get("Content-Type")).toBe("text/plain");
expect([...target].length).toBe(1);
});
it("sets singular headers via set(), overriding existing values", () => {
const target = new Headers({ "Cache-Control": "no-store", "X-Custom": "original" });
const mwHeaders = new Headers();
mwHeaders.set("Cache-Control", "private, max-age=5");
mwHeaders.set("X-Custom", "from-middleware");
mergeMiddlewareResponseHeaders(target, mwHeaders);
expect(target.get("Cache-Control")).toBe("private, max-age=5");
expect(target.get("X-Custom")).toBe("from-middleware");
});
it("appends Set-Cookie headers instead of overriding", () => {
const target = new Headers();
target.append("Set-Cookie", "existing=1; Path=/");
const mwHeaders = new Headers();
mwHeaders.append("Set-Cookie", "mw-session=abc; Path=/");
mergeMiddlewareResponseHeaders(target, mwHeaders);
const cookies = target.getSetCookie();
expect(cookies).toContain("existing=1; Path=/");
expect(cookies).toContain("mw-session=abc; Path=/");
});
it("appends Vary headers instead of overriding", () => {
const target = new Headers({ Vary: "RSC, Accept" });
const mwHeaders = new Headers();
mwHeaders.set("Vary", "Next-Router-State-Tree");
mergeMiddlewareResponseHeaders(target, mwHeaders);
expect(target.get("Vary")).toBe("RSC, Accept, Next-Router-State-Tree");
});
it("deduplicates Vary values when appending middleware headers", () => {
const target = new Headers({ Vary: VINEXT_RSC_VARY_HEADER });
const mwHeaders = new Headers();
mwHeaders.set("Vary", "next-router-state-tree, X-Auth-State");
mergeMiddlewareResponseHeaders(target, mwHeaders);
expect(target.get("Vary")).toBe(`${VINEXT_RSC_VARY_HEADER}, X-Auth-State`);
});
it("preserves wildcard Vary semantics when appending middleware headers", () => {
const target = new Headers({ Vary: "RSC, Accept" });
const mwHeaders = new Headers();
mwHeaders.set("Vary", "*, X-Auth-State");
mergeMiddlewareResponseHeaders(target, mwHeaders);
expect(target.get("Vary")).toBe("*");
});
it("preserves wildcard Vary semantics when target already has Vary wildcard", () => {
const target = new Headers({ Vary: "*" });
const mwHeaders = new Headers();
mwHeaders.set("Vary", "X-Auth-State");
mergeMiddlewareResponseHeaders(target, mwHeaders);
expect(target.get("Vary")).toBe("*");
});
it("preserves wildcard Vary semantics when middleware is the first Vary source", () => {
const target = new Headers();
const mwHeaders = new Headers();
mwHeaders.set("Vary", "*, X-Auth-State");
mergeMiddlewareResponseHeaders(target, mwHeaders);
expect(target.get("Vary")).toBe("*");
});
});