mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
5343a3ff32
* fix(app-router): cache segment prefetches by rendered search params * fix(app-router): protect rendered prefetch alias header * fix(app-router): repair segment prefetch merge
696 lines
22 KiB
TypeScript
696 lines
22 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_COMPATIBILITY_ID_HEADER,
|
|
VINEXT_RSC_VARY_HEADER,
|
|
} from "../packages/vinext/src/server/app-rsc-cache-busting.js";
|
|
import {
|
|
VINEXT_DYNAMIC_STALE_TIME_HEADER,
|
|
VINEXT_RENDERED_PATH_AND_SEARCH_HEADER,
|
|
} from "../packages/vinext/src/server/headers.js";
|
|
import { withEnvVar } from "./env-test-helpers.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,
|
|
isDraftMode: 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,
|
|
isDraftMode: false,
|
|
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,
|
|
isDraftMode: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: true,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
revalidateSeconds: 60,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "no-store, must-revalidate",
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageRscResponsePolicy({
|
|
dynamicUsedDuringBuild: false,
|
|
isDraftMode: 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,
|
|
isDraftMode: 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,
|
|
isDraftMode: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
revalidateSeconds: 60,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "no-store, must-revalidate",
|
|
});
|
|
});
|
|
|
|
it("resolves draft mode response policies as uncacheable", () => {
|
|
expect(
|
|
resolveAppPageRscResponsePolicy({
|
|
dynamicUsedDuringBuild: false,
|
|
isDraftMode: true,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
revalidateSeconds: 60,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "no-store, must-revalidate",
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageHtmlResponsePolicy({
|
|
dynamicUsedDuringRender: false,
|
|
hasScriptNonce: false,
|
|
isDraftMode: true,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
revalidateSeconds: 60,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "no-store, must-revalidate",
|
|
shouldWriteToCache: false,
|
|
});
|
|
});
|
|
|
|
it("resolves HTML response policy precedence", () => {
|
|
expect(
|
|
resolveAppPageHtmlResponsePolicy({
|
|
dynamicUsedDuringRender: true,
|
|
hasScriptNonce: false,
|
|
isDraftMode: 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,
|
|
isDraftMode: 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,
|
|
isDraftMode: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
revalidateSeconds: Infinity,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "s-maxage=31536000, stale-while-revalidate",
|
|
cacheState: "MISS",
|
|
shouldWriteToCache: true,
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageHtmlResponsePolicy({
|
|
dynamicUsedDuringRender: false,
|
|
hasScriptNonce: false,
|
|
isDraftMode: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: false,
|
|
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,
|
|
isDraftMode: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
revalidateSeconds: 60,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "s-maxage=60, stale-while-revalidate",
|
|
cacheState: "MISS",
|
|
shouldWriteToCache: true,
|
|
});
|
|
});
|
|
|
|
it("writes force-static HTML responses to cache in production", () => {
|
|
expect(
|
|
resolveAppPageHtmlResponsePolicy({
|
|
dynamicUsedDuringRender: false,
|
|
hasScriptNonce: false,
|
|
isDraftMode: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: true,
|
|
isProduction: true,
|
|
revalidateSeconds: null,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "s-maxage=31536000, stale-while-revalidate",
|
|
cacheState: "MISS",
|
|
shouldWriteToCache: true,
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageHtmlResponsePolicy({
|
|
dynamicUsedDuringRender: false,
|
|
hasScriptNonce: false,
|
|
isDraftMode: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: true,
|
|
isProduction: false,
|
|
revalidateSeconds: null,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "s-maxage=31536000, stale-while-revalidate",
|
|
cacheState: "STATIC",
|
|
shouldWriteToCache: false,
|
|
});
|
|
});
|
|
|
|
it("treats progressive action HTML responses as no-store", () => {
|
|
expect(
|
|
resolveAppPageHtmlResponsePolicy({
|
|
dynamicUsedDuringRender: false,
|
|
isProgressiveActionRender: true,
|
|
hasScriptNonce: false,
|
|
isDraftMode: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
revalidateSeconds: 60,
|
|
}),
|
|
).toEqual({
|
|
cacheControl: "no-store, must-revalidate",
|
|
shouldWriteToCache: false,
|
|
});
|
|
});
|
|
|
|
it("treats revalidate = 0 as no-store in RSC response policy", () => {
|
|
expect(
|
|
resolveAppPageRscResponsePolicy({
|
|
dynamicUsedDuringBuild: false,
|
|
isDraftMode: 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,
|
|
isDraftMode: 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,
|
|
isDraftMode: 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,
|
|
isDraftMode: 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,
|
|
isDraftMode: 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,
|
|
isDraftMode: 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,
|
|
isDraftMode: false,
|
|
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");
|
|
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("x-nextjs-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("emits the `x-edge-runtime: 1` marker on RSC responses when the route opts into the edge runtime (issue #1531)", () => {
|
|
// Next.js sets `x-edge-runtime: 1` on edge-runtime app responses (see
|
|
// edge-ssr-app.ts in the Next.js source). Mirror that only for routes
|
|
// whose resolved segment config is `runtime = "edge"`.
|
|
const response = buildAppPageRscResponse(createBody("flight"), {
|
|
isEdgeRuntime: true,
|
|
middlewareContext: { headers: null, status: null },
|
|
policy: {},
|
|
});
|
|
|
|
expect(response.headers.get("x-edge-runtime")).toBe("1");
|
|
});
|
|
|
|
it("omits the `x-edge-runtime` marker on RSC responses for nodejs-runtime routes", () => {
|
|
const response = buildAppPageRscResponse(createBody("flight"), {
|
|
middlewareContext: { headers: null, status: null },
|
|
policy: {},
|
|
});
|
|
|
|
expect(response.headers.get("x-edge-runtime")).toBeNull();
|
|
});
|
|
|
|
it("builds RSC responses with the current compatibility ID header", () => {
|
|
const response = withEnvVar("__VINEXT_RSC_COMPATIBILITY_ID", "compat-a", () =>
|
|
buildAppPageRscResponse(createBody("flight"), {
|
|
middlewareContext: { headers: null, status: null },
|
|
policy: {},
|
|
}),
|
|
);
|
|
|
|
expect(response.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-a");
|
|
});
|
|
|
|
it("emits per-page dynamic stale time metadata on RSC responses", () => {
|
|
// Next.js sends the per-page dynamic stale time in the Flight response `d`
|
|
// field; vinext carries the same response-level contract through an RSC
|
|
// response header that the client cache can snapshot.
|
|
const response = buildAppPageRscResponse(createBody("flight"), {
|
|
dynamicStaleTimeSeconds: 60,
|
|
middlewareContext: { headers: null, status: null },
|
|
policy: {},
|
|
});
|
|
|
|
expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe("60");
|
|
});
|
|
|
|
it("keeps the framework rendered path when middleware sets the internal alias header", () => {
|
|
const middlewareHeaders = new Headers({
|
|
[VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]: encodeURIComponent("/spoofed?searchParam=wrong"),
|
|
});
|
|
|
|
const response = buildAppPageRscResponse(createBody("flight"), {
|
|
middlewareContext: { headers: middlewareHeaders, status: null },
|
|
policy: {},
|
|
renderedPathAndSearch: "/search-params/target-page?searchParam=rewrittenSearchParam",
|
|
});
|
|
|
|
expect(response.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe(
|
|
encodeURIComponent("/search-params/target-page?searchParam=rewrittenSearchParam"),
|
|
);
|
|
});
|
|
|
|
it("keeps the framework compatibility ID when middleware sets the internal header", () => {
|
|
const middlewareHeaders = new Headers({
|
|
[VINEXT_RSC_COMPATIBILITY_ID_HEADER]: "middleware-compat",
|
|
});
|
|
|
|
const response = withEnvVar("__VINEXT_RSC_COMPATIBILITY_ID", "framework-compat", () =>
|
|
buildAppPageRscResponse(createBody("flight"), {
|
|
middlewareContext: { headers: middlewareHeaders, status: null },
|
|
policy: {},
|
|
}),
|
|
);
|
|
|
|
expect(response.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("framework-compat");
|
|
});
|
|
|
|
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=/",
|
|
linkHeader: "</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("x-nextjs-cache")).toBe("HIT");
|
|
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>");
|
|
});
|
|
|
|
it("emits the `x-edge-runtime: 1` marker on HTML responses when the route opts into the edge runtime (issue #1531)", () => {
|
|
// Next.js sets `x-edge-runtime: 1` on edge-runtime app responses (see
|
|
// edge-ssr-app.ts in the Next.js source). Mirror that only for routes
|
|
// whose resolved segment config is `runtime = "edge"`.
|
|
const response = buildAppPageHtmlResponse(createBody("<h1>page</h1>"), {
|
|
isEdgeRuntime: true,
|
|
middlewareContext: { headers: null, status: null },
|
|
policy: {},
|
|
});
|
|
|
|
expect(response.headers.get("x-edge-runtime")).toBe("1");
|
|
});
|
|
|
|
it("omits the `x-edge-runtime` marker on HTML responses for nodejs-runtime routes", () => {
|
|
const response = buildAppPageHtmlResponse(createBody("<h1>page</h1>"), {
|
|
middlewareContext: { headers: null, status: null },
|
|
policy: {},
|
|
});
|
|
|
|
expect(response.headers.get("x-edge-runtime")).toBeNull();
|
|
});
|
|
});
|
|
|
|
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("*");
|
|
});
|
|
});
|