mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
8d00797ada
* refactor(cache): extract Cloudflare cache adapters into @vinext/cloudflare Move the Cloudflare KV data cache and edge CDN cache adapters out of vinext into a new publishable @vinext/cloudflare package: - cache/kv-data-adapter(.runtime).ts (KVCacheHandler, kvDataAdapter) - cache/cdn-adapter(.runtime).ts (CloudflareCdnCacheAdapter, cdnAdapter) tpr.ts stays in vinext. vinext now depends on @vinext/cloudflare (workspace:*) and the package declares vinext as a peer dep; both build from source via tsconfig paths so there is no build-order cycle. The vinext/cloudflare barrel still re-exports KVCacheHandler for back-compat. Wires up tsconfig paths, a vitest source alias, root build/postinstall, and the preview/publish workflows for the new package. Updates internal consumers (apps/web, examples/workers-cache), docs, and tests. * ci(create-next-app): install @vinext/cloudflare from local tarball vinext now depends on @vinext/cloudflare, which isn't published to npm yet. The create-next-app smoke test packs vinext locally and resolves its deps from the registry, so the install (and dev server) failed with ERR_PNPM_FETCH_404 for @vinext/cloudflare. Pack @vinext/cloudflare alongside vinext and add a pnpm override in the scaffolded project pointing at the local tarball so the dependency resolves offline. * refactor(cloudflare): address review feedback - Remove the root barrel export from @vinext/cloudflare; expose only the ./cache/* subpaths via a wildcard export (no root main/types). - vinext/cloudflare re-exports KVCacheHandler from the full subpath. - Drop the redundant .npmignore (the package.json "files" allowlist already restricts the publish to dist). - Remove the unsupported imperative setCacheHandler/KVCacheHandler usage from both READMEs; the cache plugin config is the supported approach. - Simplify test wiring: drop the now-unused @vinext/cloudflare tsconfig path and dedupe the vitest source alias into a shared constant. * chore(cloudflare): drop unused vite devDependency The @vinext/cloudflare config uses vite-plus and nothing imports vite, so the vite devDependency was unused. build/check/knip stay green without it. * Apply suggestion from @james-elicx
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
import { describe, expect, it, afterEach } from "vite-plus/test";
|
|
import type { CachedRouteValue } from "../packages/vinext/src/shims/cache.js";
|
|
import {
|
|
applyRouteHandlerMiddlewareContext,
|
|
applyRouteHandlerRevalidateHeader,
|
|
assertSupportedAppRouteHandlerResponse,
|
|
buildAppRouteCacheValue,
|
|
buildRouteHandlerCachedResponse,
|
|
finalizeRouteHandlerResponse,
|
|
markRouteHandlerCacheMiss,
|
|
} from "../packages/vinext/src/server/app-route-handler-response.js";
|
|
import { setCdnCacheAdapter } from "../packages/vinext/src/shims/cdn-cache.js";
|
|
import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js";
|
|
|
|
function buildCachedRouteValue(
|
|
body: string,
|
|
headers: Record<string, string> = {},
|
|
): CachedRouteValue {
|
|
return {
|
|
kind: "APP_ROUTE",
|
|
body: new TextEncoder().encode(body).buffer,
|
|
status: 200,
|
|
headers,
|
|
};
|
|
}
|
|
|
|
describe("app route handler response helpers", () => {
|
|
it("returns the original response when no middleware context exists", () => {
|
|
const response = new Response("hello");
|
|
|
|
expect(
|
|
applyRouteHandlerMiddlewareContext(response, {
|
|
headers: null,
|
|
status: null,
|
|
}),
|
|
).toBe(response);
|
|
});
|
|
|
|
it("overrides singular headers and status with middleware values", async () => {
|
|
const response = new Response("hello", {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
"cache-control": "s-maxage=60, stale-while-revalidate",
|
|
"x-response": "app",
|
|
},
|
|
});
|
|
|
|
const result = applyRouteHandlerMiddlewareContext(response, {
|
|
headers: new Headers([
|
|
["cache-control", "private, max-age=5"],
|
|
["x-middleware", "mw"],
|
|
["x-response", "middleware-copy"],
|
|
]),
|
|
status: 202,
|
|
});
|
|
|
|
expect(result.status).toBe(202);
|
|
expect(result.headers.get("content-type")).toBe("text/plain");
|
|
expect(result.headers.get("cache-control")).toBe("private, max-age=5");
|
|
expect(result.headers.get("x-response")).toBe("middleware-copy");
|
|
expect(result.headers.get("x-middleware")).toBe("mw");
|
|
await expect(result.text()).resolves.toBe("hello");
|
|
});
|
|
|
|
it("appends additive middleware headers for Set-Cookie and Vary", async () => {
|
|
const response = new Response("hello", {
|
|
status: 200,
|
|
headers: [
|
|
["vary", "RSC, Accept"],
|
|
["set-cookie", "existing=1; Path=/"],
|
|
],
|
|
});
|
|
|
|
const middlewareHeaders = new Headers();
|
|
middlewareHeaders.append("vary", "Next-Router-State-Tree");
|
|
middlewareHeaders.append("set-cookie", "mw=1; Path=/");
|
|
middlewareHeaders.append("set-cookie", "mw=2; Path=/; HttpOnly");
|
|
|
|
const result = applyRouteHandlerMiddlewareContext(response, {
|
|
headers: middlewareHeaders,
|
|
status: null,
|
|
});
|
|
|
|
expect(result.headers.get("vary")).toBe("RSC, Accept, Next-Router-State-Tree");
|
|
expect(result.headers.getSetCookie()).toEqual([
|
|
"existing=1; Path=/",
|
|
"mw=1; Path=/",
|
|
"mw=2; Path=/; HttpOnly",
|
|
]);
|
|
await expect(result.text()).resolves.toBe("hello");
|
|
});
|
|
|
|
it("builds cached HIT and STALE route handler responses", async () => {
|
|
const cachedValue = buildCachedRouteValue("from-cache", {
|
|
"content-type": "text/plain",
|
|
});
|
|
|
|
const hit = buildRouteHandlerCachedResponse(cachedValue, {
|
|
cacheState: "HIT",
|
|
expireSeconds: 300,
|
|
isHead: false,
|
|
revalidateSeconds: 60,
|
|
});
|
|
expect(hit.headers.get("x-vinext-cache")).toBe("HIT");
|
|
expect(hit.headers.get("x-nextjs-cache")).toBe("HIT");
|
|
expect(hit.headers.get("cache-control")).toBe("s-maxage=60, stale-while-revalidate");
|
|
await expect(hit.text()).resolves.toBe("from-cache");
|
|
|
|
const staleHead = buildRouteHandlerCachedResponse(cachedValue, {
|
|
cacheState: "STALE",
|
|
expireSeconds: 300,
|
|
isHead: true,
|
|
revalidateSeconds: 60,
|
|
});
|
|
expect(staleHead.headers.get("x-vinext-cache")).toBe("STALE");
|
|
expect(staleHead.headers.get("x-nextjs-cache")).toBe("STALE");
|
|
expect(staleHead.headers.get("cache-control")).toBe("s-maxage=0, stale-while-revalidate");
|
|
await expect(staleHead.text()).resolves.toBe("");
|
|
});
|
|
|
|
it("prefers stored cache-control metadata over caller defaults", () => {
|
|
const cachedValue = buildCachedRouteValue("from-cache");
|
|
|
|
const response = buildRouteHandlerCachedResponse(cachedValue, {
|
|
cacheControl: { revalidate: 15, expire: 300 },
|
|
cacheState: "HIT",
|
|
expireSeconds: 31_536_000,
|
|
isHead: false,
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=15, stale-while-revalidate=285");
|
|
});
|
|
|
|
it("emits static cache-control for revalidateSeconds = Infinity (revalidate = false)", () => {
|
|
const cachedValue = buildCachedRouteValue("from-cache");
|
|
const response = buildRouteHandlerCachedResponse(cachedValue, {
|
|
cacheState: "HIT",
|
|
isHead: false,
|
|
revalidateSeconds: Infinity,
|
|
});
|
|
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=31536000, stale-while-revalidate");
|
|
});
|
|
|
|
it("applies revalidate header for Infinity as static cache-control", () => {
|
|
const response = new Response("fresh");
|
|
applyRouteHandlerRevalidateHeader(response, Infinity);
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=31536000, stale-while-revalidate");
|
|
});
|
|
|
|
it("serializes APP_ROUTE cache values without cache bookkeeping headers", async () => {
|
|
const response = new Response("cache me", {
|
|
status: 201,
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
"cache-control": "s-maxage=60, stale-while-revalidate",
|
|
"x-vinext-cache": "MISS",
|
|
"x-nextjs-cache": "MISS",
|
|
"x-middleware-set-cookie": "internal=1; Path=/",
|
|
"x-extra": "kept",
|
|
},
|
|
});
|
|
|
|
const value = await buildAppRouteCacheValue(response);
|
|
|
|
expect(value.kind).toBe("APP_ROUTE");
|
|
expect(value.status).toBe(201);
|
|
expect(value.headers).toEqual({
|
|
"content-type": "text/plain",
|
|
"x-extra": "kept",
|
|
});
|
|
expect(new TextDecoder().decode(value.body)).toBe("cache me");
|
|
});
|
|
|
|
it("preserves multiple Set-Cookie headers when building cache value", async () => {
|
|
const response = new Response("with cookies", {
|
|
status: 200,
|
|
headers: [
|
|
["content-type", "application/json"],
|
|
["set-cookie", "session=abc; Path=/; HttpOnly"],
|
|
["set-cookie", "theme=dark; Path=/"],
|
|
["set-cookie", "lang=en; Path=/; SameSite=Lax"],
|
|
],
|
|
});
|
|
|
|
const value = await buildAppRouteCacheValue(response);
|
|
|
|
expect(value.headers["set-cookie"]).toEqual([
|
|
"session=abc; Path=/; HttpOnly",
|
|
"theme=dark; Path=/",
|
|
"lang=en; Path=/; SameSite=Lax",
|
|
]);
|
|
expect(value.headers["content-type"]).toBe("application/json");
|
|
});
|
|
|
|
it("omits set-cookie key when response has no Set-Cookie headers", async () => {
|
|
const response = new Response("no cookies", {
|
|
status: 200,
|
|
headers: { "content-type": "text/plain" },
|
|
});
|
|
|
|
const value = await buildAppRouteCacheValue(response);
|
|
|
|
expect(value.headers).toEqual({ "content-type": "text/plain" });
|
|
expect(value.headers["set-cookie"]).toBeUndefined();
|
|
});
|
|
|
|
it("round-trips multiple Set-Cookie headers through cache store and restore", async () => {
|
|
const original = new Response("round trip", {
|
|
status: 200,
|
|
headers: [
|
|
["content-type", "text/plain"],
|
|
["set-cookie", "a=1; Path=/"],
|
|
["set-cookie", "b=2; Path=/"],
|
|
],
|
|
});
|
|
|
|
const cached = await buildAppRouteCacheValue(original);
|
|
const restored = buildRouteHandlerCachedResponse(cached, {
|
|
cacheState: "HIT",
|
|
isHead: false,
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
expect(restored.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]);
|
|
await expect(restored.text()).resolves.toBe("round trip");
|
|
});
|
|
|
|
it("finalizes route handler responses with cookies and auto-head semantics", async () => {
|
|
const response = new Response("body", {
|
|
status: 202,
|
|
statusText: "Accepted",
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
},
|
|
});
|
|
|
|
const result = finalizeRouteHandlerResponse(response, {
|
|
pendingCookies: ["a=1; Path=/"],
|
|
draftCookie: "draft=1; Path=/",
|
|
isHead: true,
|
|
});
|
|
|
|
expect(result.status).toBe(202);
|
|
expect(result.statusText).toBe("Accepted");
|
|
expect(result.headers.getSetCookie?.()).toEqual(["a=1; Path=/", "draft=1; Path=/"]);
|
|
await expect(result.text()).resolves.toBe("");
|
|
});
|
|
|
|
it("uses mutable cookies as fallbacks and keeps returned response cookies final", async () => {
|
|
// Matches Next.js appendMutableCookies:
|
|
// packages/next/src/server/web/spec-extension/adapters/request-cookies.ts
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/web/spec-extension/adapters/request-cookies.ts
|
|
const response = new Response("body", {
|
|
headers: [
|
|
["Set-Cookie", "session=returned; Path=/; HttpOnly"],
|
|
["Set-Cookie", "response-only=1; Path=/"],
|
|
],
|
|
});
|
|
|
|
const result = finalizeRouteHandlerResponse(response, {
|
|
pendingCookies: [
|
|
"session=mutable; Path=/",
|
|
"mutable-only=first; Path=/",
|
|
"mutable-only=final; Path=/; Secure",
|
|
],
|
|
draftCookie: null,
|
|
isHead: false,
|
|
});
|
|
|
|
expect(result.headers.getSetCookie()).toEqual([
|
|
"mutable-only=final; Path=/; Secure",
|
|
"session=returned; Path=/; HttpOnly",
|
|
"response-only=1; Path=/",
|
|
]);
|
|
await expect(result.text()).resolves.toBe("body");
|
|
});
|
|
|
|
it("normalizes returned response cookies when they override mutable cookie fallbacks", async () => {
|
|
// 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
|
|
const response = new Response("body", {
|
|
headers: [
|
|
["Set-Cookie", "bar=bar2"],
|
|
["Set-Cookie", "baz=baz2"],
|
|
],
|
|
});
|
|
|
|
const result = finalizeRouteHandlerResponse(response, {
|
|
pendingCookies: [
|
|
"foo=foo1; Path=/",
|
|
"bar=bar1; Path=/",
|
|
"test1=value1; Path=/; Secure",
|
|
"test2=value2; Path=/handler; HttpOnly",
|
|
],
|
|
draftCookie: null,
|
|
isHead: false,
|
|
});
|
|
|
|
expect(result.headers.getSetCookie()).toEqual([
|
|
"foo=foo1; Path=/",
|
|
"test1=value1; Path=/; Secure",
|
|
"test2=value2; Path=/handler; HttpOnly",
|
|
"bar=bar2; Path=/",
|
|
"baz=baz2; Path=/",
|
|
]);
|
|
await expect(result.text()).resolves.toBe("body");
|
|
});
|
|
|
|
it("strips internal middleware headers from finalized route handler responses", async () => {
|
|
const response = new Response("body", {
|
|
headers: [
|
|
["set-cookie", "session=abc; Path=/"],
|
|
["x-middleware-set-cookie", "session=abc; Path=/"],
|
|
],
|
|
});
|
|
|
|
const result = finalizeRouteHandlerResponse(response, {
|
|
pendingCookies: [],
|
|
isHead: false,
|
|
});
|
|
|
|
expect(result.headers.get("x-middleware-set-cookie")).toBeNull();
|
|
expect(result.headers.getSetCookie()).toEqual(["session=abc; Path=/"]);
|
|
await expect(result.text()).resolves.toBe("body");
|
|
});
|
|
|
|
it("applies revalidate and MISS headers separately", () => {
|
|
const response = new Response("hello");
|
|
|
|
applyRouteHandlerRevalidateHeader(response, 30);
|
|
markRouteHandlerCacheMiss(response);
|
|
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=30, stale-while-revalidate");
|
|
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
|
|
});
|
|
|
|
it("only rejects the active x-middleware-next control signal", () => {
|
|
expect(() =>
|
|
assertSupportedAppRouteHandlerResponse(
|
|
new Response(null, {
|
|
headers: { "x-middleware-next": "0" },
|
|
}),
|
|
),
|
|
).not.toThrow();
|
|
expect(() =>
|
|
assertSupportedAppRouteHandlerResponse(
|
|
new Response(null, {
|
|
headers: { "x-middleware-next": "true" },
|
|
}),
|
|
),
|
|
).not.toThrow();
|
|
expect(() =>
|
|
assertSupportedAppRouteHandlerResponse(
|
|
new Response(null, {
|
|
headers: { "x-middleware-next": "1" },
|
|
}),
|
|
),
|
|
).toThrow("NextResponse.next() was used in a app route handler");
|
|
});
|
|
|
|
it("emits a no-store Cache-Control for revalidate = 0 route handlers", () => {
|
|
// A handler exporting `revalidate = 0` opts out of caching entirely.
|
|
// The Cache-Control must tell browsers and CDNs never to store the
|
|
// response. Emitting `s-maxage=0, stale-while-revalidate` (the SWR
|
|
// template) would still permit intermediate caches to serve stale
|
|
// copies, which is the exact opposite of the author's intent.
|
|
// The exact string matches Next.js's own cache-control helper:
|
|
// .nextjs-ref/packages/next/src/server/lib/cache-control.ts:29.
|
|
const response = new Response("no cache me");
|
|
|
|
applyRouteHandlerRevalidateHeader(response, 0);
|
|
|
|
expect(response.headers.get("cache-control")).toBe(
|
|
"private, no-cache, no-store, max-age=0, must-revalidate",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("route handler responses route through the CDN cache adapter", () => {
|
|
const CDN_KEY = Symbol.for("vinext.cdnCacheAdapter");
|
|
afterEach(() => {
|
|
delete (globalThis as Record<PropertyKey, unknown>)[CDN_KEY];
|
|
});
|
|
|
|
it("emits CDN-Cache-Control + Cache-Tag on a fresh response when an edge adapter is active", () => {
|
|
setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
|
|
const response = new Response("fresh");
|
|
|
|
applyRouteHandlerRevalidateHeader(response, 60, 600, ["_N_T_/api/feed", "posts"]);
|
|
|
|
expect(response.headers.get("Cache-Control")).toBe("public, max-age=0, must-revalidate");
|
|
expect(response.headers.get("CDN-Cache-Control")).toBe(
|
|
"public, max-age=60, stale-while-revalidate=540",
|
|
);
|
|
expect(response.headers.get("Cache-Tag")).toBe("_N_T_/api/feed,posts");
|
|
});
|
|
|
|
it("does not promote a revalidate=0 (non-cacheable) response to the edge", () => {
|
|
setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
|
|
const response = new Response("dynamic");
|
|
|
|
applyRouteHandlerRevalidateHeader(response, 0);
|
|
|
|
expect(response.headers.get("Cache-Control")).toBe(
|
|
"private, no-cache, no-store, max-age=0, must-revalidate",
|
|
);
|
|
expect(response.headers.get("CDN-Cache-Control")).toBeNull();
|
|
});
|
|
});
|