All files / src/lib cache-headers.ts

100% Statements 18/18
100% Branches 14/14
100% Functions 2/2
100% Lines 13/13

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90                                          1x                                 1x                 13x 13x 13x 4x                                                           19x 17x 16x 15x 7x 6x   13x    
// In Astro 5, `Astro.response` is only available on AstroGlobal (the `Astro`
// object in .astro frontmatter), not on APIContext/AstroSharedContext.
// We accept the minimal structural shape needed so callers pass `Astro` directly.
export interface AstroResponseContext {
	response: {
		headers: Pick<Headers, "set" | "get">;
	};
}
 
export type CachePreset =
	| "static"
	| "city-hub"
	| "listing"
	| "detail"
	| "blog-post"
	| "faq-hub"
	| "sitemap"
	| "search"
	| "account"
	| "calculator";
 
const CACHE_CONTROL: Record<CachePreset, string> = {
	static: "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800",
	"city-hub":
		"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400",
	listing: "public, max-age=60, s-maxage=600, stale-while-revalidate=3600",
	detail: "public, max-age=60, s-maxage=600, stale-while-revalidate=86400",
	"blog-post":
		"public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800",
	"faq-hub":
		"public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800",
	sitemap: "public, s-maxage=172800, stale-while-revalidate=604800",
	calculator:
		"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400",
	search: "private, no-cache",
	account: "private, no-store",
};
 
const ROBOTS_TAG: Partial<Record<CachePreset, string>> = {
	search: "noindex",
	account: "noindex, nofollow",
};
 
export function setCacheHeaders(
	ctx: AstroResponseContext,
	preset: CachePreset,
): void {
	ctx.response.headers.set("Cache-Control", CACHE_CONTROL[preset]);
	const robotsTag = ROBOTS_TAG[preset];
	if (robotsTag !== undefined) {
		ctx.response.headers.set("X-Robots-Tag", robotsTag);
	}
}
 
// Fail closed: only `production` is indexable. Any other value (or undefined)
// gets noindex. Misconfigured prod that loses the env var stays uncrawled —
// vastly preferable to dev/preview leaking into the index.
//
// Exception: Lighthouse audits bypass the noindex header so they see
// prod-equivalent SEO scores. Three opt-in signals (any one suffices):
//   1. Query param `_lh` on the URL — set by our `lighthouse:audit`
//      runner as a per-run cache-buster, so it's already on every URL
//      we audit. Lighthouse 13 strips "HeadlessChrome" from the network
//      UA so a UA-only check can't detect it from inside the worker.
//   2. UA contains "Chrome-Lighthouse" (Lighthouse <13 default).
//   3. UA contains "HeadlessChrome" — covers other headless tools (the
//      official Lighthouse 13 only shows this in hostUserAgent, not
//      networkUserAgent, so this is a fallback).
//   4. Custom header "x-lighthouse-audit: 1" — explicit opt-in for
//      manual curl audits or alternative tools.
// Canonical URLs are rendered server-side from the pathname (no query
// string), so a leaked `?_lh=...` URL still won't be indexed by Google.
// Real crawlers (Googlebot, Bingbot, etc.) never match any of these.
export function setEnvRobotTag(
	ctx: AstroResponseContext,
	envName: string | undefined,
	userAgent?: string | null,
	auditHeader?: string | null,
	hasAuditQueryParam?: boolean,
): void {
	if (envName === "production") return;
	if (hasAuditQueryParam) return;
	if (auditHeader === "1") return;
	if (userAgent) {
		if (userAgent.includes("Chrome-Lighthouse")) return;
		if (userAgent.includes("HeadlessChrome")) return;
	}
	ctx.response.headers.set("X-Robots-Tag", "noindex, nofollow");
}