mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
9d5d1ce50d
* 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.
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import {
|
|
computeRscCacheBustingSearchParam,
|
|
createRscRequestHeaders,
|
|
createRscRequestUrl,
|
|
} from "../packages/vinext/src/server/app-rsc-cache-busting.js";
|
|
import { createAppRscHandler } from "../packages/vinext/src/server/app-rsc-handler.js";
|
|
import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js";
|
|
|
|
type TestRoute = {
|
|
page?: { default?: unknown } | null;
|
|
pattern: string;
|
|
rootParamNames?: readonly string[];
|
|
routeHandler?: { GET?: () => Response } | null;
|
|
routeSegments: readonly string[];
|
|
};
|
|
|
|
type HandlerOptions = Parameters<typeof createAppRscHandler<TestRoute>>[0];
|
|
|
|
function createPageRoute(overrides: Partial<TestRoute> = {}): TestRoute {
|
|
return {
|
|
page: { default() {} },
|
|
pattern: "/about",
|
|
routeSegments: ["about"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createHandler(overrides: Partial<HandlerOptions> = {}) {
|
|
const route = createPageRoute();
|
|
|
|
return createAppRscHandler<TestRoute>({
|
|
basePath: "/docs",
|
|
clearRequestContext: overrides.clearRequestContext ?? (() => {}),
|
|
configHeaders: overrides.configHeaders ?? [
|
|
{
|
|
source: "/about",
|
|
headers: [{ key: "x-test-header", value: "applied" }],
|
|
},
|
|
],
|
|
configRedirects: overrides.configRedirects ?? [],
|
|
configRewrites: overrides.configRewrites ?? {
|
|
afterFiles: [],
|
|
beforeFiles: [],
|
|
fallback: [],
|
|
},
|
|
dispatchMatchedPage:
|
|
overrides.dispatchMatchedPage ??
|
|
(async () => new Response("page", { status: 200, headers: { "x-from-dispatch": "page" } })),
|
|
dispatchMatchedRouteHandler:
|
|
overrides.dispatchMatchedRouteHandler ?? (async () => new Response("route", { status: 200 })),
|
|
ensureInstrumentation: overrides.ensureInstrumentation,
|
|
handleProgressiveActionRequest: overrides.handleProgressiveActionRequest ?? (async () => null),
|
|
handleServerActionRequest: overrides.handleServerActionRequest ?? (async () => null),
|
|
i18nConfig: overrides.i18nConfig ?? null,
|
|
isMiddlewareProxy: overrides.isMiddlewareProxy ?? false,
|
|
makeThenableParams,
|
|
matchRoute:
|
|
overrides.matchRoute ??
|
|
((pathname: string) =>
|
|
pathname === "/about"
|
|
? {
|
|
params: {},
|
|
route,
|
|
}
|
|
: null),
|
|
metadataRoutes: overrides.metadataRoutes ?? [],
|
|
middlewareModule: overrides.middlewareModule ?? null,
|
|
publicFiles: overrides.publicFiles ?? new Set<string>(),
|
|
renderNotFound: overrides.renderNotFound ?? (async () => null),
|
|
renderPagesFallback: overrides.renderPagesFallback,
|
|
rootParamNamesByPattern: overrides.rootParamNamesByPattern,
|
|
setNavigationContext: overrides.setNavigationContext ?? (() => {}),
|
|
staticParamsMap: overrides.staticParamsMap ?? {},
|
|
trailingSlash: overrides.trailingSlash ?? false,
|
|
validateDevRequestOrigin: overrides.validateDevRequestOrigin ?? (() => null),
|
|
});
|
|
}
|
|
|
|
describe("createAppRscHandler", () => {
|
|
it("wraps dispatch responses with request-scoped finalization", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({ dispatchMatchedPage });
|
|
|
|
const response = await handler(new Request("https://example.test/docs/about"), null);
|
|
|
|
expect(dispatchMatchedPage).toHaveBeenCalledTimes(1);
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("x-test-header")).toBe("applied");
|
|
});
|
|
|
|
it("returns config redirects before route dispatch and skips finalization", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({
|
|
configRedirects: [{ source: "/old-about", destination: "/about", permanent: true }],
|
|
dispatchMatchedPage,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/old-about"), null);
|
|
|
|
expect(response.status).toBe(308);
|
|
expect(response.headers.get("location")).toBe("/docs/about");
|
|
expect(response.headers.get("x-test-header")).toBeNull();
|
|
expect(dispatchMatchedPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("canonicalizes config redirect locations for RSC requests", async () => {
|
|
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
|
|
const expectedHash = await computeRscCacheBustingSearchParam(headers);
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
configRedirects: [{ source: "/old-about", destination: "/about?from=old", permanent: false }],
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/old-about.rsc", { headers }),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe(
|
|
`https://example.test/docs/about.rsc?from=old&_rsc=${expectedHash}`,
|
|
);
|
|
});
|
|
|
|
it("redirects invalid RSC cache-busting requests before middleware", async () => {
|
|
const middleware = vi.fn(() => new Response("middleware"));
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
|
|
const expectedHash = await computeRscCacheBustingSearchParam(headers);
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
dispatchMatchedPage,
|
|
middlewareModule: { default: middleware },
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/about.rsc?tab=latest", { headers }),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe(
|
|
`/docs/about.rsc?tab=latest&_rsc=${expectedHash}`,
|
|
);
|
|
expect(middleware).not.toHaveBeenCalled();
|
|
expect(dispatchMatchedPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not render RSC payloads at HTML URLs marked only by RSC headers", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
dispatchMatchedPage,
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/about", {
|
|
headers: createRscRequestHeaders(),
|
|
}),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(dispatchMatchedPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cleanPathname: "/about",
|
|
isRscRequest: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("strips internal RSC cache-busting params before setting navigation context", async () => {
|
|
const setNavigationContext = vi.fn();
|
|
const headers = createRscRequestHeaders();
|
|
const rscUrl = await createRscRequestUrl("/docs/about?tab=latest", headers);
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
setNavigationContext,
|
|
});
|
|
|
|
const response = await handler(new Request(`https://example.test${rscUrl}`, { headers }), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(setNavigationContext).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
pathname: "/about",
|
|
params: {},
|
|
}),
|
|
);
|
|
const context = setNavigationContext.mock.lastCall?.[0];
|
|
expect(context?.searchParams.get("tab")).toBe("latest");
|
|
expect(context?.searchParams.has("_rsc")).toBe(false);
|
|
});
|
|
|
|
it("runs beforeFiles rewrites before route matching", async () => {
|
|
const matchRoute = vi.fn((pathname: string) =>
|
|
pathname === "/about"
|
|
? {
|
|
params: {},
|
|
route: createPageRoute(),
|
|
}
|
|
: null,
|
|
);
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("rewritten", { status: 200 }));
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
configRewrites: {
|
|
beforeFiles: [{ source: "/alias", destination: "/about" }],
|
|
afterFiles: [],
|
|
fallback: [],
|
|
},
|
|
dispatchMatchedPage,
|
|
matchRoute,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/alias"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("rewritten");
|
|
expect(matchRoute).toHaveBeenLastCalledWith("/about");
|
|
expect(dispatchMatchedPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cleanPathname: "/about" }),
|
|
);
|
|
});
|
|
|
|
it("serves public files before route matching and clears request context", async () => {
|
|
const clearRequestContext = vi.fn();
|
|
const matchRoute = vi.fn(() => null);
|
|
const handler = createHandler({
|
|
clearRequestContext,
|
|
configHeaders: [],
|
|
matchRoute,
|
|
publicFiles: new Set(["/logo.svg"]),
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/logo.svg"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("x-vinext-static-file")).toBe("%2Flogo.svg");
|
|
expect(clearRequestContext).toHaveBeenCalledTimes(1);
|
|
expect(matchRoute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets server actions short-circuit routing while still applying final headers", async () => {
|
|
const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 }));
|
|
const handleServerActionRequest = vi.fn(
|
|
async () => new Response("action", { status: 200, headers: { "x-action": "done" } }),
|
|
);
|
|
const handler = createHandler({
|
|
configRewrites: {
|
|
beforeFiles: [],
|
|
afterFiles: [{ source: "/about", destination: "/rewritten-action" }],
|
|
fallback: [],
|
|
},
|
|
dispatchMatchedPage,
|
|
handleServerActionRequest,
|
|
});
|
|
|
|
const response = await handler(
|
|
new Request("https://example.test/docs/about", {
|
|
method: "POST",
|
|
headers: { "next-action": "abc123" },
|
|
}),
|
|
null,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("action");
|
|
expect(response.headers.get("x-action")).toBe("done");
|
|
expect(response.headers.get("x-test-header")).toBe("applied");
|
|
expect(handleServerActionRequest).toHaveBeenCalledWith(
|
|
expect.objectContaining({ actionId: "abc123", cleanPathname: "/about" }),
|
|
);
|
|
expect(dispatchMatchedPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts the vinext action header name for server actions", async () => {
|
|
const handleServerActionRequest = vi.fn(async () => new Response("action", { status: 200 }));
|
|
const handler = createHandler({ handleServerActionRequest });
|
|
|
|
await handler(
|
|
new Request("https://example.test/docs/about", {
|
|
method: "POST",
|
|
headers: { "x-rsc-action": "vinext-action" },
|
|
}),
|
|
null,
|
|
);
|
|
|
|
expect(handleServerActionRequest).toHaveBeenCalledWith(
|
|
expect.objectContaining({ actionId: "vinext-action" }),
|
|
);
|
|
});
|
|
|
|
it("dispatches route handlers with matched params", async () => {
|
|
const route = createPageRoute({
|
|
page: null,
|
|
pattern: "/api/:id",
|
|
routeHandler: { GET: () => new Response("route") },
|
|
routeSegments: ["api", "[id]"],
|
|
});
|
|
const dispatchMatchedRouteHandler = vi.fn(async () => new Response("route", { status: 200 }));
|
|
const handler = createHandler({
|
|
configHeaders: [],
|
|
dispatchMatchedRouteHandler,
|
|
matchRoute: (pathname: string) =>
|
|
pathname === "/api/123"
|
|
? {
|
|
params: { id: "123" },
|
|
route,
|
|
}
|
|
: null,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/api/123"), null);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(dispatchMatchedRouteHandler).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cleanPathname: "/api/123",
|
|
params: { id: "123" },
|
|
route,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("clears request context before returning the plain 404 fallback", async () => {
|
|
const clearRequestContext = vi.fn();
|
|
const handler = createHandler({
|
|
clearRequestContext,
|
|
configHeaders: [],
|
|
matchRoute: () => null,
|
|
renderNotFound: async () => null,
|
|
});
|
|
|
|
const response = await handler(new Request("https://example.test/docs/missing"), null);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.text()).toBe("Not Found");
|
|
expect(clearRequestContext).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|