Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import {
OAUTH_DEVICE_AUTHORIZATION_ENDPOINT_PATH,
OAUTH_TOKEN_ENDPOINT_PATH,
} from "@/lib/oauth/constants";
import {
PRIVATE_LOCALE_CATALOG_HEADERS,
PUBLIC_LOCALE_CATALOG_PAGE_HEADERS,
} from "@/lib/public-cache-control";
import {
buildContentSecurityPolicy,
createScriptNonce,
Expand Down Expand Up @@ -110,6 +114,35 @@ function isHtmlResponse(response: Response) {
return response.headers.get("content-type")?.includes("text/html");
}

function isCatalogPageRequest(event: Parameters<Handle>[0]["event"]) {
return (
event.request.method === "GET" &&
(event.url.pathname === "/catalog" ||
event.url.pathname.startsWith("/catalog/"))
);
}

// Catalog pages render identical content for every anonymous viewer, so let
// the CDN cache them briefly instead of re-running SSR for every crawler hit.
// Requests carrying an auth signal stay private/no-store so personalized
// renders can never be cached.
function applyCatalogCacheControl(
headers: Headers,
input: { hasAuthSignal: boolean; isCatalogPage: boolean },
) {
if (!input.isCatalogPage) {
headers.set("Cache-Control", "no-store");
return;
}

const catalogHeaders = input.hasAuthSignal
? PRIVATE_LOCALE_CATALOG_HEADERS
: PUBLIC_LOCALE_CATALOG_PAGE_HEADERS;
for (const [name, value] of Object.entries(catalogHeaders)) {
headers.set(name, value);
}
}

function addScriptNonce(html: string, nonce: string) {
return html.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`);
}
Expand Down Expand Up @@ -313,7 +346,10 @@ const handleWithRuntimeEnv: Handle = async ({ event, resolve }) => {

mutableResponse.headers.set("Content-Language", locale);
if (!mutableResponse.headers.has("Cache-Control")) {
mutableResponse.headers.set("Cache-Control", "no-store");
applyCatalogCacheControl(mutableResponse.headers, {
hasAuthSignal,
isCatalogPage: isCatalogPageRequest(event),
});
}
setContentSignal(mutableResponse.headers);
mutableResponse.headers.set(
Expand Down
10 changes: 10 additions & 0 deletions src/lib/public-cache-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,13 @@ export const PRIVATE_LOCALE_CATALOG_HEADERS = {
"Cloudflare-CDN-Cache-Control": "no-store",
Vary: "Accept-Language, Cookie",
} as const;

// HTML page variant: no stale-while-revalidate for the browser. Browsers may
// serve an SWR-cached document without revalidating, which breaks flows that
// mutate state and expect a fresh render on the next navigation (locale
// switch, login, subscribe). The edge keeps its own SWR window.
export const PUBLIC_LOCALE_CATALOG_PAGE_HEADERS = {
"Cache-Control": "public, max-age=0",
"Cloudflare-CDN-Cache-Control": PUBLIC_CATALOG_CDN_CACHE_CONTROL,
Vary: "Accept-Language, Cookie",
} as const;
125 changes: 125 additions & 0 deletions tests/unit/catalog-cache-control.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { Handle } from "@sveltejs/kit";
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("@/app-env", () => ({
getOptionalTrimmedEnv: (name: string) =>
name === "NODE_ENV" ? "development" : undefined,
loadEnv: vi.fn(),
}));

// Avoid building a real better-auth instance when a request carries an auth
// signal; cache-control decisions only depend on the signal, not the session.
vi.mock("@/lib/auth/core", () => ({
getSessionFromHeadersWithResponseHeaders: async () => null,
}));

import { handle } from "@/hooks.server";

function htmlResponse(headers?: HeadersInit) {
return new Response('<html lang="zh-CN"><body>catalog</body></html>', {
headers: { "content-type": "text/html; charset=utf-8", ...headers },
});
}

function handleInput(
resolve: Parameters<Handle>[0]["resolve"],
input: {
headers?: HeadersInit;
method?: string;
pathname?: string;
routeId?: Parameters<Handle>[0]["event"]["route"]["id"];
} = {},
) {
const url = new URL(
`https://life.example${input.pathname ?? "/catalog/sections/161022"}`,
);
const event: Parameters<Handle>[0]["event"] = {
cookies: {
delete: vi.fn(),
get: vi.fn(),
getAll: vi.fn(() => []),
serialize: vi.fn(() => ""),
set: vi.fn(),
},
fetch,
getClientAddress: () => "127.0.0.1",
isDataRequest: false,
isRemoteRequest: false,
isSubRequest: false,
locals: {
authUser: null,
locale: "zh-cn",
requestId: "",
},
params: {},
platform: undefined,
request: new Request(url, {
headers: input.headers,
method: input.method ?? "GET",
}),
route: { id: input.routeId ?? "/catalog/sections/[jwId]" },
setHeaders: vi.fn(),
tracing: undefined,
url,
} as unknown as Parameters<Handle>[0]["event"];
return { event, resolve };
}

describe("catalog page cache control", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("lets the CDN briefly cache anonymous catalog pages", async () => {
vi.spyOn(console, "info").mockImplementation(() => {});

const response = await handle(handleInput(async () => htmlResponse()));

expect(response.headers.get("Cache-Control")).toBe("public, max-age=0");
expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBe(
"public, max-age=60, stale-while-revalidate=300",
);
expect(response.headers.get("Vary")).toBe("Accept-Language, Cookie");
});

it("keeps authenticated catalog pages private and uncacheable", async () => {
vi.spyOn(console, "info").mockImplementation(() => {});

const response = await handle(
handleInput(async () => htmlResponse(), {
headers: { cookie: "better-auth.session_token=token-value" },
}),
);

expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBe(
"no-store",
);
});

it("keeps non-catalog pages on the no-store default", async () => {
vi.spyOn(console, "info").mockImplementation(() => {});

const response = await handle(
handleInput(async () => htmlResponse(), {
pathname: "/account/sign-in",
routeId: "/account/sign-in",
}),
);

expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBeNull();
});

it("never overrides an explicit Cache-Control from the route", async () => {
vi.spyOn(console, "info").mockImplementation(() => {});

const response = await handle(
handleInput(async () =>
htmlResponse({ "Cache-Control": "public, max-age=300" }),
),
);

expect(response.headers.get("Cache-Control")).toBe("public, max-age=300");
});
});