mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
9ec793244e
* fix(app-router): send deployment id on RSC responses * test(app-router): isolate deployment id integration coverage
403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
import { describe, expect, it } from "vite-plus/test";
|
|
import {
|
|
applyRscCompatibilityIdHeader,
|
|
applyRscDeploymentIdHeader,
|
|
computeRscCacheBustingSearchParam,
|
|
createRscRequestHeaders,
|
|
createRscRequestUrl,
|
|
createServerActionRequestUrl,
|
|
isRscCompatibilityIdCompatible,
|
|
resolveInvalidRscCacheBustingRequest,
|
|
setRscCacheBustingSearchParam,
|
|
stripRscCacheBustingSearchParam,
|
|
VINEXT_RSC_COMPATIBILITY_ID_HEADER,
|
|
VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM,
|
|
VINEXT_RSC_RENDER_MODE_HEADER,
|
|
VINEXT_RSC_VARY_HEADER,
|
|
} from "../packages/vinext/src/server/app-rsc-cache-busting.js";
|
|
import {
|
|
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
|
|
APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
|
|
} from "../packages/vinext/src/server/app-rsc-render-mode.js";
|
|
import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER } from "../packages/vinext/src/server/headers.js";
|
|
import { fnv1a64 } from "../packages/vinext/src/utils/hash.js";
|
|
import { withEnvVar } from "./env-test-helpers.js";
|
|
|
|
const textEncoder = new TextEncoder();
|
|
|
|
function encodeBase64Url(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
for (const byte of bytes) {
|
|
binary += String.fromCharCode(byte);
|
|
}
|
|
|
|
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
}
|
|
|
|
async function sha256CacheBustingHash(input: string): Promise<string> {
|
|
const digest = await globalThis.crypto.subtle.digest("SHA-256", textEncoder.encode(input));
|
|
return encodeBase64Url(new Uint8Array(digest).subarray(0, 12));
|
|
}
|
|
|
|
describe("App Router RSC cache-busting", () => {
|
|
// Ported from Next.js: test/production/deployment-id-handling/deployment-id-handling.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/production/deployment-id-handling/deployment-id-handling.test.ts
|
|
it("adds the deployment ID header to RSC requests", () => {
|
|
withEnvVar("__VINEXT_DEPLOYMENT_ID", "dpl_123", () => {
|
|
expect(createRscRequestHeaders().get("x-deployment-id")).toBe("dpl_123");
|
|
});
|
|
});
|
|
|
|
it("adds a bare _rsc search param when no variant headers are present", async () => {
|
|
const headers = createRscRequestHeaders();
|
|
|
|
await expect(createRscRequestUrl("/dashboard?tab=activity", headers)).resolves.toBe(
|
|
"/dashboard?tab=activity&_rsc",
|
|
);
|
|
});
|
|
|
|
it("uses the canonical route URL for root RSC navigations", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts
|
|
// Client-side App Router navigations fetch the route URL with RSC: 1 and
|
|
// _rsc cache busting, not Vinext's legacy /.rsc transport path.
|
|
const headers = createRscRequestHeaders();
|
|
|
|
await expect(createRscRequestUrl("/", headers)).resolves.toBe("/?_rsc");
|
|
});
|
|
|
|
it("preserves the route pathname trailing slash when building canonical RSC URLs", async () => {
|
|
const headers = createRscRequestHeaders();
|
|
|
|
await expect(createRscRequestUrl("/docs/", headers)).resolves.toBe("/docs/?_rsc");
|
|
});
|
|
|
|
it("hashes Vinext RSC variant headers into the request URL", async () => {
|
|
const headers = createRscRequestHeaders({
|
|
interceptionContext: "/feed",
|
|
mountedSlotsHeader: "slot:modal:/ slot:sidebar:/",
|
|
});
|
|
|
|
const hash = await computeRscCacheBustingSearchParam(headers);
|
|
|
|
expect(hash).not.toBe("");
|
|
await expect(createRscRequestUrl("/photos/42", headers)).resolves.toBe(
|
|
`/photos/42?${VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM}=${hash}`,
|
|
);
|
|
});
|
|
|
|
it("keeps server action POSTs on the visible route URL", () => {
|
|
// Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts
|
|
expect(createServerActionRequestUrl("/server?name=alice#section")).toBe("/server?name=alice");
|
|
});
|
|
|
|
it("attaches client reuse manifests without making them shared cache variants", async () => {
|
|
const manifestHeader = '{"entries":[]}';
|
|
const headers = createRscRequestHeaders({ clientReuseManifestHeader: manifestHeader });
|
|
|
|
expect(headers.get(VINEXT_CLIENT_REUSE_MANIFEST_HEADER)).toBe(manifestHeader);
|
|
await expect(createRscRequestUrl("/dashboard", headers)).resolves.toBe("/dashboard?_rsc");
|
|
});
|
|
|
|
it("changes the hash when a varying header changes", async () => {
|
|
const feedHash = await computeRscCacheBustingSearchParam(
|
|
createRscRequestHeaders({ interceptionContext: "/feed" }),
|
|
);
|
|
const galleryHash = await computeRscCacheBustingSearchParam(
|
|
createRscRequestHeaders({ interceptionContext: "/gallery" }),
|
|
);
|
|
|
|
expect(feedHash).not.toBe(galleryHash);
|
|
});
|
|
|
|
it("varies preserve-current-UI refresh payloads from normal navigations", async () => {
|
|
const navigationHash = await computeRscCacheBustingSearchParam(createRscRequestHeaders());
|
|
const refreshHash = await computeRscCacheBustingSearchParam(
|
|
createRscRequestHeaders({ renderMode: APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI }),
|
|
);
|
|
|
|
expect(navigationHash).toBe("");
|
|
expect(refreshHash).not.toBe("");
|
|
});
|
|
|
|
it("varies loading-shell prefetch payloads from normal navigations", async () => {
|
|
const navigationHash = await computeRscCacheBustingSearchParam(createRscRequestHeaders());
|
|
const prefetchShellHash = await computeRscCacheBustingSearchParam(
|
|
createRscRequestHeaders({ renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL }),
|
|
);
|
|
|
|
expect(navigationHash).toBe("");
|
|
expect(prefetchShellHash).not.toBe("");
|
|
});
|
|
|
|
it("normalizes invalid render modes to normal navigation for cache-busting", async () => {
|
|
const headers = createRscRequestHeaders();
|
|
headers.set(VINEXT_RSC_RENDER_MODE_HEADER, "invalid");
|
|
|
|
await expect(computeRscCacheBustingSearchParam(headers)).resolves.toBe("");
|
|
});
|
|
|
|
it("preserves existing query params while replacing stale _rsc values", () => {
|
|
const url = new URL("https://example.com/photos/42.rsc?tab=latest&_rsc=stale");
|
|
|
|
setRscCacheBustingSearchParam(url, "fresh");
|
|
|
|
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?tab=latest&_rsc=fresh");
|
|
});
|
|
|
|
it("replaces encoded reserved _rsc query keys", () => {
|
|
const url = new URL("https://example.com/photos/42.rsc?%5Frsc=stale&tab=latest");
|
|
|
|
setRscCacheBustingSearchParam(url, "fresh");
|
|
|
|
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?tab=latest&_rsc=fresh");
|
|
});
|
|
|
|
it("does not treat query keys containing _rsc as cache-busting params", () => {
|
|
const url = new URL("https://example.com/photos/42.rsc?filter_rsc=1&_rsc=stale");
|
|
|
|
setRscCacheBustingSearchParam(url, "fresh");
|
|
|
|
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?filter_rsc=1&_rsc=fresh");
|
|
});
|
|
|
|
it("strips internal _rsc params before exposing response URLs to browser navigation", () => {
|
|
const url = new URL("https://example.com/photos/42.rsc?tab=latest&_rsc=fresh&view=modal");
|
|
|
|
stripRscCacheBustingSearchParam(url);
|
|
|
|
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?tab=latest&view=modal");
|
|
});
|
|
|
|
it("strips encoded reserved _rsc query keys before exposing response URLs", () => {
|
|
const url = new URL("https://example.com/photos/42.rsc?filter_rsc=1&%5Frsc=stale");
|
|
|
|
stripRscCacheBustingSearchParam(url);
|
|
|
|
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?filter_rsc=1");
|
|
});
|
|
|
|
it("strips bare internal _rsc params without rewriting unrelated query encoding", () => {
|
|
const url = new URL("https://example.com/search.rsc?q=custom%20spacing&_rsc");
|
|
|
|
stripRscCacheBustingSearchParam(url);
|
|
|
|
expect(`${url.pathname}${url.search}`).toBe("/search.rsc?q=custom%20spacing");
|
|
});
|
|
|
|
it("redirects RSC requests with missing cache-busting params to the canonical URL", async () => {
|
|
const headers = createRscRequestHeaders({ interceptionContext: "/feed" });
|
|
const request = new Request("https://example.com/photos/42.rsc?tab=latest", { headers });
|
|
const hash = await computeRscCacheBustingSearchParam(headers);
|
|
|
|
const response = await resolveInvalidRscCacheBustingRequest({
|
|
isRscRequest: true,
|
|
request,
|
|
});
|
|
|
|
expect(response?.status).toBe(307);
|
|
expect(response?.headers.get("Location")).toBe(`/photos/42.rsc?tab=latest&_rsc=${hash}`);
|
|
});
|
|
|
|
it("redirects encoded stale _rsc keys to a canonical non-looping URL", async () => {
|
|
const headers = createRscRequestHeaders();
|
|
const request = new Request("https://example.com/photos/42.rsc?%5Frsc=stale", { headers });
|
|
|
|
const response = await resolveInvalidRscCacheBustingRequest({
|
|
isRscRequest: true,
|
|
request,
|
|
});
|
|
|
|
expect(response?.status).toBe(307);
|
|
expect(response?.headers.get("Location")).toBe("/photos/42.rsc?_rsc");
|
|
});
|
|
|
|
it("accepts RSC requests without cache-busting params when no variant headers are present", async () => {
|
|
const headers = createRscRequestHeaders();
|
|
const request = new Request("https://example.com/photos/42.rsc?tab=latest", { headers });
|
|
|
|
await expect(
|
|
resolveInvalidRscCacheBustingRequest({ isRscRequest: true, request }),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("redirects HTML-path RSC requests without cache-busting params to a separate URL", async () => {
|
|
const headers = createRscRequestHeaders();
|
|
const request = new Request("https://example.com/photos/42?tab=latest", { headers });
|
|
|
|
const response = await resolveInvalidRscCacheBustingRequest({
|
|
isRscRequest: true,
|
|
request,
|
|
});
|
|
|
|
expect(response?.status).toBe(307);
|
|
expect(response?.headers.get("Location")).toBe("/photos/42?tab=latest&_rsc");
|
|
});
|
|
|
|
it("accepts RSC requests whose cache-busting param matches the request headers", async () => {
|
|
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
|
|
const url = await createRscRequestUrl("/photos/42", headers);
|
|
const request = new Request(`https://example.com${url}`, { headers });
|
|
|
|
await expect(
|
|
resolveInvalidRscCacheBustingRequest({ isRscRequest: true, request }),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("accepts legacy FNV cache-busting params during rolling upgrades", async () => {
|
|
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
|
|
const legacyHash = fnv1a64("0,0,0,0,0,slot:modal:/");
|
|
const request = new Request(`https://example.com/photos/42.rsc?_rsc=${legacyHash}`, {
|
|
headers,
|
|
});
|
|
|
|
await expect(
|
|
resolveInvalidRscCacheBustingRequest({ isRscRequest: true, request }),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("accepts previous SHA cache-busting params after adding a varying header", async () => {
|
|
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
|
|
const previousHash = await sha256CacheBustingHash("0,0,0,0,0,slot:modal:/");
|
|
const request = new Request(`https://example.com/photos/42.rsc?_rsc=${previousHash}`, {
|
|
headers,
|
|
});
|
|
|
|
await expect(
|
|
resolveInvalidRscCacheBustingRequest({ isRscRequest: true, request }),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("does not accept a bare previous hash for preserve-current-UI payloads", async () => {
|
|
const headers = createRscRequestHeaders({
|
|
renderMode: APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
|
|
});
|
|
const request = new Request("https://example.com/photos/42.rsc?_rsc", { headers });
|
|
const hash = await computeRscCacheBustingSearchParam(headers);
|
|
|
|
const response = await resolveInvalidRscCacheBustingRequest({
|
|
isRscRequest: true,
|
|
request,
|
|
});
|
|
|
|
expect(response?.status).toBe(307);
|
|
expect(response?.headers.get("Location")).toBe(`/photos/42.rsc?_rsc=${hash}`);
|
|
});
|
|
|
|
it("does not accept previous mounted-slot hashes for preserve-current-UI payloads", async () => {
|
|
const headers = createRscRequestHeaders({
|
|
mountedSlotsHeader: "slot:modal:/",
|
|
renderMode: APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
|
|
});
|
|
const previousHash = await sha256CacheBustingHash("0,0,0,0,0,slot:modal:/");
|
|
const currentHash = await computeRscCacheBustingSearchParam(headers);
|
|
const request = new Request(`https://example.com/photos/42.rsc?_rsc=${previousHash}`, {
|
|
headers,
|
|
});
|
|
|
|
const response = await resolveInvalidRscCacheBustingRequest({
|
|
isRscRequest: true,
|
|
request,
|
|
});
|
|
|
|
expect(response?.status).toBe(307);
|
|
expect(response?.headers.get("Location")).toBe(`/photos/42.rsc?_rsc=${currentHash}`);
|
|
});
|
|
|
|
it("ignores non-RSC and mutating requests", async () => {
|
|
const headers = createRscRequestHeaders({ interceptionContext: "/feed" });
|
|
|
|
await expect(
|
|
resolveInvalidRscCacheBustingRequest({
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/photos/42", { headers }),
|
|
}),
|
|
).resolves.toBeNull();
|
|
await expect(
|
|
resolveInvalidRscCacheBustingRequest({
|
|
isRscRequest: true,
|
|
request: new Request("https://example.com/photos/42.rsc", { headers, method: "POST" }),
|
|
}),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("exports the full Vary value for RSC-bearing App Router responses", () => {
|
|
expect(VINEXT_RSC_VARY_HEADER).toBe(
|
|
"RSC, Accept, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url, X-Vinext-Interception-Context, X-Vinext-Mounted-Slots, X-Vinext-Rsc-Render-Mode",
|
|
);
|
|
});
|
|
|
|
it("applies the current compatibility ID to RSC response headers when available", () => {
|
|
const headers = new Headers();
|
|
|
|
applyRscCompatibilityIdHeader(headers, "compat-a");
|
|
|
|
expect(headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-a");
|
|
});
|
|
|
|
it("uses the injected RSC compatibility ID by default", () => {
|
|
const headers = new Headers();
|
|
|
|
withEnvVar("__VINEXT_RSC_COMPATIBILITY_ID", "compat-env", () =>
|
|
applyRscCompatibilityIdHeader(headers),
|
|
);
|
|
|
|
expect(headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-env");
|
|
});
|
|
|
|
it("leaves the Next.js deployment ID header out of compatibility-only response headers", () => {
|
|
const headers = new Headers();
|
|
|
|
withEnvVar("__VINEXT_DEPLOYMENT_ID", "deployment-a", () =>
|
|
applyRscCompatibilityIdHeader(headers, "compat-a"),
|
|
);
|
|
|
|
expect(headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-a");
|
|
expect(headers.has("x-nextjs-deployment-id")).toBe(false);
|
|
});
|
|
|
|
it("applies the Next.js deployment ID header to App Router RSC page response headers", () => {
|
|
const headers = new Headers();
|
|
|
|
withEnvVar("__VINEXT_DEPLOYMENT_ID", "deployment-a", () => applyRscDeploymentIdHeader(headers));
|
|
|
|
expect(headers.get("x-nextjs-deployment-id")).toBe("deployment-a");
|
|
});
|
|
|
|
it("removes a spoofed Next.js deployment ID header when none is configured", () => {
|
|
const headers = new Headers({
|
|
"x-nextjs-deployment-id": "spoofed-deployment",
|
|
});
|
|
|
|
withEnvVar("__VINEXT_DEPLOYMENT_ID", undefined, () =>
|
|
withEnvVar("NEXT_DEPLOYMENT_ID", undefined, () => applyRscDeploymentIdHeader(headers)),
|
|
);
|
|
|
|
expect(headers.has("x-nextjs-deployment-id")).toBe(false);
|
|
});
|
|
|
|
it("removes a spoofed compatibility ID header when no framework ID is available", () => {
|
|
const headers = new Headers({
|
|
[VINEXT_RSC_COMPATIBILITY_ID_HEADER]: "spoofed-compat",
|
|
});
|
|
|
|
applyRscCompatibilityIdHeader(headers, "");
|
|
|
|
expect(headers.has(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe(false);
|
|
});
|
|
|
|
it("classifies mismatched RSC compatibility IDs as incompatible", () => {
|
|
expect(isRscCompatibilityIdCompatible("compat-a", "compat-a")).toBe(true);
|
|
expect(isRscCompatibilityIdCompatible("compat-b", "compat-a")).toBe(false);
|
|
});
|
|
|
|
it("treats missing response compatibility IDs as incompatible when the client has one", () => {
|
|
expect(isRscCompatibilityIdCompatible(null, "compat-a")).toBe(false);
|
|
});
|
|
|
|
it("treats missing response compatibility IDs as compatible only when the client has none", () => {
|
|
expect(isRscCompatibilityIdCompatible("compat-a", null)).toBe(true);
|
|
});
|
|
});
|