Files
cloudflare__vinext/tests/app-page-response.test.ts
T
Steve Faulkner 6cfdaa60a8 fix: percent-encode X-Vinext-Params to allow non-ASCII route params (#677)
Headers.set() requires ByteString values (chars <= U+00FF). When dynamic
route params contain non-ASCII characters (Korean, Japanese, etc.),
JSON.stringify preserves them verbatim which causes a TypeError on the
Headers.set() call in buildAppPageRscResponse.

Fix: encodeURIComponent the JSON on the server side before setting the
header, and decodeURIComponent on the client side when reading it back
during initial hydration and client-side navigation.

Closes #676
2026-03-24 10:06:06 -05:00

291 lines
9.3 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
buildAppPageHtmlResponse,
buildAppPageRscResponse,
resolveAppPageHtmlResponsePolicy,
resolveAppPageRscResponsePolicy,
} from "../packages/vinext/src/server/app-page-response.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,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "s-maxage=60, stale-while-revalidate",
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,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "no-store, must-revalidate",
shouldWriteToCache: false,
});
expect(
resolveAppPageHtmlResponsePolicy({
dynamicUsedDuringRender: 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,
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,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: false,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "s-maxage=60, stale-while-revalidate",
cacheState: "MISS",
shouldWriteToCache: true,
});
});
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,
isDynamicError: false,
isForceDynamic: false,
isForceStatic: true,
isProduction: true,
revalidateSeconds: 60,
}),
).toEqual({
cacheControl: "s-maxage=60, stale-while-revalidate",
cacheState: "MISS",
shouldWriteToCache: true,
});
});
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("RSC, Accept, Next-Router-State-Tree");
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 draft cookies, preload links, middleware, and timing", async () => {
const middlewareHeaders = new Headers();
middlewareHeaders.append("set-cookie", "mw=1; Path=/");
middlewareHeaders.append("x-extra", "present");
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("s-maxage=31536000, stale-while-revalidate");
expect(response.headers.get("x-vinext-cache")).toBe("STATIC");
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>");
});
});