diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx index 6d9ae8ea..88240aa0 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx @@ -1,7 +1,9 @@ "use client"; +import { headerAssertion } from "@openstatus/assertions"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; +import { useQueryStates } from "nuqs"; import { EmptyStateContainer, @@ -17,10 +19,54 @@ import { import { FormGeneral } from "@/components/forms/monitor/form-general"; import { useTRPC } from "@/lib/trpc/client"; +import { searchParamsParsers } from "./search-params"; + +function safeHostname(url: string) { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +// prefill from play tools (e.g. /play/cdn-checker): malformed params fall +// back to an empty form rather than erroring +function buildPrefill(params: { + url: string | null; + name: string | null; + assertionHeaderKey: string | null; + assertionHeaderCompare: string | null; + assertionHeaderValue: string | null; +}): React.ComponentProps["defaultValues"] { + if (!params.url) return undefined; + + const assertion = headerAssertion.safeParse({ + type: "header", + version: "v1", + compare: params.assertionHeaderCompare ?? "eq", + key: params.assertionHeaderKey, + target: params.assertionHeaderValue, + }); + + return { + active: true, + name: params.name ?? safeHostname(params.url), + type: "http", + method: "GET", + url: params.url, + headers: [], + body: "", + assertions: assertion.success ? [assertion.data] : [], + skipCheck: false, + saveCheck: false, + }; +} + export default function Page() { const trpc = useTRPC(); const queryClient = useQueryClient(); const router = useRouter(); + const [params] = useQueryStates(searchParamsParsers); const triggerCheckMutation = useMutation( trpc.checker.triggerChecker.mutationOptions({}), @@ -47,6 +93,7 @@ export default function Page() { Create Monitor { await createMonitorMutation.mutateAsync({ name: data.name, diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/search-params.ts b/apps/dashboard/src/app/(dashboard)/monitors/create/search-params.ts new file mode 100644 index 00000000..66d3fd07 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/search-params.ts @@ -0,0 +1,12 @@ +import { createSearchParamsCache, parseAsString } from "nuqs/server"; + +// prefill contract used by the play tools (e.g. /play/cdn-checker) +export const searchParamsParsers = { + url: parseAsString, + name: parseAsString, + assertionHeaderKey: parseAsString, + assertionHeaderCompare: parseAsString, + assertionHeaderValue: parseAsString, +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/web/deno.jsonc b/apps/web/deno.jsonc new file mode 100644 index 00000000..d3de83dd --- /dev/null +++ b/apps/web/deno.jsonc @@ -0,0 +1,6 @@ +/* Needed to fix the import aliases */ +{ + "imports": { + "@/": "./src/", + }, +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/(landing)/play/cdn-checker/api/route.ts b/apps/web/src/app/(landing)/play/cdn-checker/api/route.ts new file mode 100644 index 00000000..8381fdd5 --- /dev/null +++ b/apps/web/src/app/(landing)/play/cdn-checker/api/route.ts @@ -0,0 +1,140 @@ +import { Events, setupAnalytics } from "@openstatus/analytics"; +import { AVAILABLE_REGIONS } from "@openstatus/regions"; +import { after } from "next/server"; +import { z } from "zod"; + +import { devProbeCdnRegion } from "@/lib/cdn-checker/dev-probe"; +import { validateCdnUrl } from "@/lib/cdn-checker/guards"; +import { probeCdnRegion } from "@/lib/cdn-checker/probe"; +import { + MAX_REQUESTS_PER_WINDOW, + RATE_LIMIT_WINDOW, + rateLimitCdnRequest, + rateLimitHeaders, +} from "@/lib/cdn-checker/ratelimit"; +import type { CdnRegionResponse } from "@/lib/cdn-checker/schema"; +import { computeCdnSummary } from "@/lib/cdn-checker/summary"; +import { iteratorToStream, yieldMany } from "@/lib/stream"; + +export const runtime = "edge"; +// 28 concurrent probes, slowest capped at 12s + summary; give some slack +export const maxDuration = 30; + +const requestSchema = z.object({ + url: z.url("Invalid URL format"), +}); + +type ErrorCode = + | "RATE_LIMIT_EXCEEDED" + | "INVALID_REQUEST" + | "NO_CLIENT_IP" + | "INTERNAL_ERROR"; + +function errorResponse( + code: ErrorCode, + error: string, + status: number, + details?: Record, + headers?: Record, +) { + return new Response(JSON.stringify({ error, code, ...details }), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +const encoder = new TextEncoder(); + +// strip query, hash and userinfo: tracked URLs must not leak tokens +function urlForAnalytics(url: string): string { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; +} + +async function* makeIterator({ url }: { url: string }) { + const rows: CdnRegionResponse[] = []; + const promises = AVAILABLE_REGIONS.map(async (region) => { + const result = + process.env.NODE_ENV === "production" + ? await probeCdnRegion({ url, region }) + : await devProbeCdnRegion({ url, region }); + rows.push(result); + return encoder.encode(`${JSON.stringify(result)}\n`); + }); + + yield* yieldMany(promises); + yield encoder.encode(`${JSON.stringify(computeCdnSummary(rows))}\n`); +} + +export async function POST(request: Request) { + let parsed: z.infer; + try { + const json = await request.json(); + const result = requestSchema.safeParse(json); + if (!result.success) { + return errorResponse("INVALID_REQUEST", "Invalid request format", 400, { + details: { + issues: result.error.issues.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + })), + }, + }); + } + parsed = result.data; + } catch { + return errorResponse( + "INVALID_REQUEST", + "Invalid JSON in request body", + 400, + ); + } + + const guard = validateCdnUrl(parsed.url); + if (!guard.ok) { + return errorResponse("INVALID_REQUEST", guard.error, guard.status); + } + + const rl = await rateLimitCdnRequest("play-cdn", request.headers); + if (rl.status === "no-client-ip") { + return errorResponse( + "NO_CLIENT_IP", + "Unable to determine client IP address", + 400, + ); + } + if (rl.status === "limited") { + return errorResponse( + "RATE_LIMIT_EXCEEDED", + `You have exceeded the rate limit of ${MAX_REQUESTS_PER_WINDOW} requests per ${RATE_LIMIT_WINDOW} seconds`, + 429, + { limit: rl.limit, remaining: rl.remaining, reset: rl.reset }, + { + ...rateLimitHeaders(rl), + "Retry-After": Math.ceil((rl.reset - Date.now()) / 1000).toString(), + }, + ); + } + + after(async () => { + try { + const analytics = await setupAnalytics({}); + await analytics.track({ + ...Events.CdnChecker, + url: urlForAnalytics(parsed.url), + }); + } catch (error) { + console.error("cdn-checker analytics failed", error); + } + }); + + // results are not persisted on purpose: cache state must be near-real-time + const stream = iteratorToStream(makeIterator({ url: parsed.url })); + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-store", + ...rateLimitHeaders(rl), + }, + }); +} diff --git a/apps/web/src/app/(landing)/play/cdn-checker/client.tsx b/apps/web/src/app/(landing)/play/cdn-checker/client.tsx new file mode 100644 index 00000000..df0a8a01 --- /dev/null +++ b/apps/web/src/app/(landing)/play/cdn-checker/client.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { Button } from "@openstatus/ui/components/ui/button"; +import { Input } from "@openstatus/ui/components/ui/input"; +import { useQueryStates } from "nuqs"; +import { + createContext, + useContext, + useEffect, + useRef, + useState, + useTransition, +} from "react"; + +import type { CdnRegionResponse, CdnSummary } from "@/lib/cdn-checker/schema"; +import { + cdnRegionResponseSchema, + cdnSummarySchema, +} from "@/lib/cdn-checker/schema"; +import { regionFormatter } from "@/lib/checker/utils"; +import { toast } from "@/lib/toast"; + +import { searchParamsParsers } from "./search-params"; + +type CdnCheckerContextType = { + rows: CdnRegionResponse[]; + summary: CdnSummary | null; + checkedUrl: string | null; + isPending: boolean; + runCheck: (url: string) => void; +}; + +// null default so out-of-provider usage throws instead of silently no-oping +const CdnCheckerContext = createContext(null); + +export function useCdnChecker() { + const context = useContext(CdnCheckerContext); + if (!context) { + throw new Error("useCdnChecker must be used within a CdnCheckerProvider"); + } + return context; +} + +export function CdnCheckerProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [rows, setRows] = useState([]); + const [summary, setSummary] = useState(null); + const [checkedUrl, setCheckedUrl] = useState(null); + const [isPending, startTransition] = useTransition(); + const [{ url: urlParam }, setSearchParams] = + useQueryStates(searchParamsParsers); + const autoRan = useRef(false); + const abortRef = useRef(null); + + // cancel the in-flight stream when the provider unmounts + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + function runCheck(url: string) { + try { + new URL(url); + } catch { + toast.error("Invalid URL"); + return; + } + + // abort the previous check so its stale rows can't interleave with ours + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setRows([]); + setSummary(null); + setCheckedUrl(url); + setSearchParams({ url }); + + startTransition(async () => { + let toastId: string | number | undefined; + try { + toastId = toast.loading("Checking cache status from all regions...", { + duration: Number.POSITIVE_INFINITY, + closeButton: false, + }); + + const response = await fetch("/play/cdn-checker/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + signal: controller.signal, + }); + + if (!response.ok) { + try { + const json = await response.json(); + toast.error(json.error, { + id: toastId, + className: "text-destructive!", + }); + } catch { + toast.error("Failed to fetch data", { + id: toastId, + description: "Please try again.", + className: "text-destructive!", + }); + } + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + toast.error("Failed to read response", { + id: toastId, + description: "Please try again.", + className: "text-destructive!", + }); + return; + } + + const decoder = new TextDecoder(); + // lines can split across chunks: buffer until a newline arrives + let buffer = ""; + let done = false; + + while (!done) { + const { value, done: streamDone } = await reader.read(); + done = streamDone; + buffer += decoder.decode(value, { stream: !done }); + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + const summaryResult = cdnSummarySchema.safeParse(parsed); + if ( + summaryResult.success && + (parsed as { type?: string }).type === "summary" + ) { + setSummary(summaryResult.data); + toast.success( + `Cached in ${summaryResult.data.cachedRegions} of ${summaryResult.data.respondedRegions} regions`, + { id: toastId, duration: 4000 }, + ); + continue; + } + + const rowResult = cdnRegionResponseSchema.safeParse(parsed); + if (rowResult.success) { + const row = rowResult.data; + setRows((prev) => [...prev, row]); + if (row.state === "success") { + toast.loading( + `${regionFormatter(row.region, "long")}: ${row.cacheStatus}`, + { id: toastId }, + ); + } + } + } + } + } catch (error) { + // deliberate abort (new submission or unmount) is not an error + if (controller.signal.aborted) { + if (toastId !== undefined) toast.dismiss(toastId); + return; + } + console.error("Error fetching data:", error); + toast.error("Something went wrong", { + id: toastId, + description: "Please try again.", + className: "text-destructive!", + }); + } + }); + } + + // ?url= present on mount: re-run the probe live (shareable result links) + // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount + useEffect(() => { + if (autoRan.current) return; + autoRan.current = true; + if (urlParam) runCheck(urlParam); + }, []); + + return ( + + {children} + + ); +} + +export function CdnForm() { + const { runCheck, isPending } = useCdnChecker(); + const [{ url: urlParam }] = useQueryStates(searchParamsParsers); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const formData = new FormData(event.target as HTMLFormElement); + runCheck(formData.get("url") as string); + } + + return ( +
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(landing)/play/cdn-checker/components/monitor-cta.tsx b/apps/web/src/app/(landing)/play/cdn-checker/components/monitor-cta.tsx new file mode 100644 index 00000000..5cdc3aa0 --- /dev/null +++ b/apps/web/src/app/(landing)/play/cdn-checker/components/monitor-cta.tsx @@ -0,0 +1,76 @@ +"use client"; + +import type { CdnProvider } from "@openstatus/header-analysis"; +import { Button } from "@openstatus/ui/components/ui/button"; + +import { APP_URL } from "@/lib/metadata/shared-metadata"; + +import { useCdnChecker } from "../client"; + +// keys in Go-canonical casing: the checker matches header assertions by +// exact key (apps/checker/pkg/assertions), not case-insensitively +const CACHE_ASSERTION: Partial< + Record< + CdnProvider, + { key: string; compare: "eq" | "contains"; target: string } + > +> = { + cloudflare: { key: "Cf-Cache-Status", compare: "eq", target: "HIT" }, + vercel: { key: "X-Vercel-Cache", compare: "eq", target: "HIT" }, + cloudfront: { key: "X-Cache", compare: "contains", target: "Hit" }, + fastly: { key: "X-Cache", compare: "contains", target: "HIT" }, + akamai: { key: "X-Cache", compare: "contains", target: "HIT" }, +}; + +export function MonitorCta() { + const { summary, checkedUrl } = useCdnChecker(); + + if (!summary || !checkedUrl) return null; + + const uncached = summary.uncachedRegions.length; + const unreachable = summary.unreachableRegions.length; + const assertion = summary.cdn ? CACHE_ASSERTION[summary.cdn] : undefined; + + const params = new URLSearchParams({ url: checkedUrl, ref: "cdn-checker" }); + if (assertion) { + params.set("assertionHeaderKey", assertion.key); + params.set("assertionHeaderCompare", assertion.compare); + params.set("assertionHeaderValue", assertion.target); + } + + return ( +
+
+

+ {uncached > 0 + ? `Your CDN is not serving from cache in ${uncached} ${ + uncached === 1 ? "region" : "regions" + }.` + : unreachable > 0 + ? `Caching looks healthy in the regions that responded (${unreachable} unreachable).` + : "Caching looks healthy — keep it that way."} +

+

+ {uncached > 0 + ? "Monitor cache status and edge latency continuously and get alerted when caching breaks. Free." + : "Monitor this URL and get alerted the moment a region stops caching. Free."} + {assertion + ? ` The monitor comes pre-filled with a ${assertion.key} assertion.` + : null} +

+
+ +
+ ); +} diff --git a/apps/web/src/app/(landing)/play/cdn-checker/components/results-table.tsx b/apps/web/src/app/(landing)/play/cdn-checker/components/results-table.tsx new file mode 100644 index 00000000..1cc40831 --- /dev/null +++ b/apps/web/src/app/(landing)/play/cdn-checker/components/results-table.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { CDN_LABELS } from "@openstatus/header-analysis"; +import { AVAILABLE_REGIONS, regionDict } from "@openstatus/regions"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@openstatus/ui/components/ui/dialog"; + +import { IconCloudProvider } from "@/components/icon-cloud-provider"; +import type { CdnRegionResult } from "@/lib/cdn-checker/schema"; +import { regionFormatter } from "@/lib/checker/utils"; +import { cn } from "@/lib/utils"; + +import { useCdnChecker } from "../client"; +import { + CACHE_STATUS_COLOR, + CACHE_STATUS_DESCRIPTION, + formatAge, +} from "../utils"; + +function CacheStatusIndicator({ + status, +}: { + status: CdnRegionResult["cacheStatus"]; +}) { + return ( +
+ ); +} + +export function CacheStatusLegend() { + return ( +
+ {Object.entries(CACHE_STATUS_COLOR).map(([status, className]) => ( +
+ {status} +
+ ))} +
+ ); +} + +function DetailsDialog({ result }: { result: CdnRegionResult }) { + const config = regionDict[result.region]; + const entries = [ + { label: "Edge", value: result.edgePop ?? "-" }, + { label: "Status", value: String(result.statusCode) }, + { label: "Age", value: formatAge(result.age) }, + { label: "CDN", value: result.cdn ? CDN_LABELS[result.cdn] : "-" }, + { label: "Cache Header", value: result.cacheStatusRaw }, + { label: "Cache-Control", value: result.cacheControl }, + { label: "ETag", value: result.etag }, + { label: "Edge Location", value: result.edgePopLocation }, + ].filter((entry) => entry.value); + + return ( + + + + + + + Cache Details + + Cache status and response headers for this region. + + +
+ + + + + + + {entries.map((entry) => ( + + + + + ))} + +
Region + {config.location} {config.flag}, {config.provider}{" "} + +
{entry.label}{entry.value}
+
+
+
+ ); +} + +export function ResultsTable() { + const { rows } = useCdnChecker(); + + const successRows = rows + .filter((row) => row.state === "success") + .sort((a, b) => a.ttfbMs - b.ttfbMs); + const errorRows = rows.filter((row) => row.state === "error"); + + return ( +
+ {errorRows.length > 0 ? ( +

+ Unreachable:{" "} + {errorRows + .map((row) => regionFormatter(row.region, "short")) + .join(", ")} +

+ ) : null} +
+ + + + + + + + + {successRows.length === 0 ? ( + + + + + + + + ) : ( + successRows.map((row) => { + const config = regionDict[row.region]; + return ( + + + + + + + + ); + }) + )} + + +
+ + RegionLatency +
+ + +
+
+
+
+
+
+
+
+ + + + + {config.flag} {config.code}{" "} + + {config.location} + + + {Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + }).format(row.ttfbMs)} + ms + + +
+ Results of your check ({rows.length} / {AVAILABLE_REGIONS.length}{" "} + regions) +
+
+
+ ); +} diff --git a/apps/web/src/app/(landing)/play/cdn-checker/components/summary-card.tsx b/apps/web/src/app/(landing)/play/cdn-checker/components/summary-card.tsx new file mode 100644 index 00000000..a4997104 --- /dev/null +++ b/apps/web/src/app/(landing)/play/cdn-checker/components/summary-card.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { CDN_LABELS } from "@openstatus/header-analysis"; + +import { regionFormatter } from "@/lib/checker/utils"; +import { cn } from "@/lib/utils"; + +import { useCdnChecker } from "../client"; + +const UNCACHED_PREVIEW_COUNT = 6; + +function ratioColor(cached: number, responded: number) { + if (responded === 0) return "text-muted-foreground"; + const ratio = cached / responded; + if (ratio >= 0.9) return "text-success"; + if (ratio >= 0.5) return "text-warning"; + return "text-destructive"; +} + +export function SummaryCard() { + const { summary } = useCdnChecker(); + + if (!summary) return null; + + const { + cachedRegions, + respondedRegions, + uncachedRegions, + unreachableRegions, + cdn, + mixedCdn, + topology, + topologyBasis, + } = summary; + + return ( +
+
+

Cache hit ratio

+

+ {cachedRegions} / {respondedRegions} +

+

+ regions served from cache + {unreachableRegions.length > 0 + ? ` (${unreachableRegions.length} unreachable)` + : null} +

+
+
+

CDN

+

+ {cdn ? CDN_LABELS[cdn] : "Not detected"} +

+

+ {mixedCdn + ? "multiple providers detected" + : topology !== "unknown" + ? `${topology}${topologyBasis === "provider" ? " (inferred)" : ""}` + : "topology unknown"} +

+
+
+

Uncached regions

+ {uncachedRegions.length === 0 ? ( +

None

+ ) : ( + <> +

+ {uncachedRegions.length} +

+

+ {uncachedRegions + .slice(0, UNCACHED_PREVIEW_COUNT) + .map((region) => regionFormatter(region, "short")) + .join(", ")} + {uncachedRegions.length > UNCACHED_PREVIEW_COUNT + ? ` +${uncachedRegions.length - UNCACHED_PREVIEW_COUNT} more` + : ""} +

+ + )} +

+ {uncachedRegions.length > 0 + ? "MISS can mean first request — run again to confirm" + : "caching looks healthy"} +

+
+
+ ); +} diff --git a/apps/web/src/app/(landing)/play/cdn-checker/page.tsx b/apps/web/src/app/(landing)/play/cdn-checker/page.tsx new file mode 100644 index 00000000..fdfaa673 --- /dev/null +++ b/apps/web/src/app/(landing)/play/cdn-checker/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { CustomMDX } from "@/content/mdx"; +import { getToolsPage } from "@/content/utils"; +import { JsonLd } from "@/lib/metadata/json-ld"; +import { BASE_URL, getPageMetadata } from "@/lib/metadata/shared-metadata"; +import { + createJsonLDGraph, + getJsonLDBreadcrumbList, + getJsonLDFAQPage, + getJsonLDWebPage, +} from "@/lib/metadata/structured-data"; + +import { CdnCheckerProvider, CdnForm } from "./client"; +import { MonitorCta } from "./components/monitor-cta"; +import { CacheStatusLegend, ResultsTable } from "./components/results-table"; +import { SummaryCard } from "./components/summary-card"; + +export function generateMetadata(): Metadata { + const page = getToolsPage("cdn-checker"); + return getPageMetadata(page, "play"); +} + +export default function Page() { + const page = getToolsPage("cdn-checker"); + + const jsonLDGraph = createJsonLDGraph([ + getJsonLDWebPage(page), + getJsonLDBreadcrumbList([ + { name: "Home", url: BASE_URL }, + { name: "Playground", url: `${BASE_URL}/play` }, + { name: page.metadata.title, url: `${BASE_URL}/play/cdn-checker` }, + ]), + getJsonLDFAQPage(page), + ]); + + return ( +
+ +

{page.metadata.hero ?? page.metadata.title}

+

{page.metadata.description}

+ + + + + + + + + +

+ Checks run live — results are not stored. +

+ +
+ ); +} diff --git a/apps/web/src/app/(landing)/play/cdn-checker/search-params.ts b/apps/web/src/app/(landing)/play/cdn-checker/search-params.ts new file mode 100644 index 00000000..62e5dd7f --- /dev/null +++ b/apps/web/src/app/(landing)/play/cdn-checker/search-params.ts @@ -0,0 +1,5 @@ +import { parseAsString } from "nuqs/server"; + +export const searchParamsParsers = { + url: parseAsString, +}; diff --git a/apps/web/src/app/(landing)/play/cdn-checker/utils.ts b/apps/web/src/app/(landing)/play/cdn-checker/utils.ts new file mode 100644 index 00000000..b5fcb5ba --- /dev/null +++ b/apps/web/src/app/(landing)/play/cdn-checker/utils.ts @@ -0,0 +1,28 @@ +import type { CacheStatus } from "@openstatus/header-analysis"; + +export const CACHE_STATUS_COLOR: Record = { + HIT: "bg-success", + STALE: "bg-success/80", + EXPIRED: "bg-warning", + MISS: "bg-destructive", + BYPASS: "bg-muted-foreground", + DYNAMIC: "bg-info", + UNKNOWN: "bg-muted-foreground", +}; + +export const CACHE_STATUS_DESCRIPTION: Record = { + HIT: "Served from the edge cache", + STALE: "Served from cache while revalidating with origin", + EXPIRED: "Found in cache but expired — fetched from origin", + MISS: "Not in cache — fetched from origin", + BYPASS: "Caching explicitly bypassed for this asset", + DYNAMIC: "Not eligible for caching", + UNKNOWN: "No cache headers detected", +}; + +export function formatAge(seconds: number | null): string { + if (seconds === null) return "-"; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + return `${Math.floor(seconds / 3600)}h`; +} diff --git a/apps/web/src/app/(landing)/play/page.tsx b/apps/web/src/app/(landing)/play/page.tsx index a7d54da9..0b9d1767 100644 --- a/apps/web/src/app/(landing)/play/page.tsx +++ b/apps/web/src/app/(landing)/play/page.tsx @@ -59,6 +59,12 @@ const PLAY = [ description: "Test the latency of your website worldwide", href: "/play/checker", }, + { + label: "CDN Cache Checker", + description: + "Check if your CDN is caching, and which edge served each region", + href: "/play/cdn-checker", + }, { label: "MCP Server Health Check", description: "JSON-RPC ping check for Model Context Protocol servers", diff --git a/apps/web/src/components/ui/data-table/data-table-column-header.tsx b/apps/web/src/components/ui/data-table/data-table-column-header.tsx new file mode 100644 index 00000000..0d63e103 --- /dev/null +++ b/apps/web/src/components/ui/data-table/data-table-column-header.tsx @@ -0,0 +1,60 @@ +import { Button } from "@openstatus/ui/components/ui/button"; +import { cn } from "@openstatus/ui/lib/utils"; +import type { Column } from "@tanstack/react-table"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +interface DataTableColumnHeaderProps< + TData, + TValue, +> extends React.ComponentProps<"button"> { + column: Column; + title: string; +} + +export function DataTableColumnHeader({ + column, + title, + className, + onClick, + ...props +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
; + } + + return ( + + ); +} diff --git a/apps/web/src/components/ui/data-table/data-table.tsx b/apps/web/src/components/ui/data-table/data-table.tsx new file mode 100644 index 00000000..926eee6a --- /dev/null +++ b/apps/web/src/components/ui/data-table/data-table.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@openstatus/ui/components/ui/table"; +import { + type ColumnDef, + type ColumnFiltersState, + type Row, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import * as React from "react"; +import { Fragment } from "react"; + +interface DataTableToolbarProps { + table: import("@tanstack/react-table").Table; +} + +// trimmed port of apps/dashboard data-table: no pagination/action bar — +// play tools render a fixed, small set of rows +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + rowComponent?: React.ComponentType<{ row: Row }>; + toolbarComponent?: React.ComponentType>; + onRowClick?: (row: Row) => void; + defaultSorting?: SortingState; + defaultColumnVisibility?: VisibilityState; + defaultColumnFilters?: ColumnFiltersState; + emptyState?: React.ReactNode; +} + +export function DataTable({ + columns, + data, + rowComponent, + toolbarComponent, + onRowClick, + defaultSorting = [], + defaultColumnVisibility = {}, + defaultColumnFilters = [], + emptyState = "No results.", +}: DataTableProps) { + const [columnVisibility, setColumnVisibility] = + React.useState(defaultColumnVisibility); + const [columnFilters, setColumnFilters] = + React.useState(defaultColumnFilters); + const [sorting, setSorting] = React.useState(defaultSorting); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + columnFilters, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getExpandedRowModel: getExpandedRowModel(), + getRowCanExpand: () => Boolean(rowComponent), + }); + + return ( +
+ {toolbarComponent + ? React.createElement(toolbarComponent, { table }) + : null} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + onRowClick?.(row)} + className="data-[state=selected]:bg-muted/50" + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + {row.getIsExpanded() && ( + + + {rowComponent + ? React.createElement(rowComponent, { row }) + : null} + + + )} + + )) + ) : ( + + + {emptyState} + + + )} + +
+
+ ); +} diff --git a/apps/web/src/content/pages/tools/cdn-checker.mdx b/apps/web/src/content/pages/tools/cdn-checker.mdx new file mode 100644 index 00000000..daf70124 --- /dev/null +++ b/apps/web/src/content/pages/tools/cdn-checker.mdx @@ -0,0 +1,99 @@ +--- +title: "CDN Cache Checker" +publishedAt: "2026-06-07" +author: "Thibault Le Ouay Ducasse" +description: "Check if your CDN is actually caching from 28 regions worldwide. See cache HIT/MISS per region, which edge served you, and which CDN a website uses." +category: "Product" +faq: + - question: "What is a CDN cache checker?" + answer: "A CDN cache checker tests whether your CDN is serving content from its edge cache instead of your origin server. OpenStatus requests your URL from 28 regions worldwide and reads the cache headers (cf-cache-status, x-cache, x-vercel-cache, age) to report HIT, MISS, EXPIRED, STALE, BYPASS or DYNAMIC per region." + - question: "Why does my CDN return MISS in some regions?" + answer: "CDN caches are regional: each edge location keeps its own copy. A MISS in a region usually means no user has requested the asset from that edge recently, the TTL expired, or your cache rules exclude it. The first request from a region is always a MISS — run the check again to confirm whether the edge cached the response." + - question: "How do I know what CDN a website is using?" + answer: "CDNs add identifying response headers. Cloudflare adds cf-ray and cf-cache-status, Amazon CloudFront adds x-amz-cf-id and x-amz-cf-pop, Fastly adds x-served-by, Vercel adds x-vercel-id. The CDN Cache Checker fingerprints these headers automatically and shows the detected provider." + - question: "What is the difference between anycast and unicast CDNs?" + answer: "With anycast, every edge location announces the same IP address and the network routes users to the nearest one. With unicast or GeoDNS, DNS hands out different IP addresses per region. Anycast typically fails over faster; GeoDNS gives the provider more routing control. The checker infers the topology from the responses." +--- + +## Start monitoring your cache + + +
+ +**Cache status per region** + +See HIT, MISS, EXPIRED, STALE or DYNAMIC for every region — read straight from your CDN's response headers, not guessed from latency. + +
+
+ +**CDN detection & edge PoP** + +Identify which CDN serves a website and which edge location (PoP) answered each region, with anycast vs unicast topology inference. + +
+
+ +**Monitor it continuously** + +Turn a one-off check into a monitor with an assertion on the cache header — get alerted the moment a region stops caching. + +
+
+ +### How CDN caching works + +A CDN keeps copies of your content on edge servers close to your users. When a request arrives, the edge either serves its cached copy (a **HIT**) or forwards the request to your origin (a **MISS**) and usually stores the response for next time. + +Caches are regional: a HIT in Frankfurt says nothing about Tokyo. That is why checking from a single location — or only from your own machine — routinely hides cold or misconfigured regions. This tool runs the same request from 28 regions at once and reads the cache headers from every response. + +### Reading cache headers: HIT vs MISS vs EXPIRED vs STALE + +Each provider reports cache state in its own header. The checker normalizes them into one status: + +| Status | Meaning | +| --- | --- | +| `HIT` | Served from the edge cache. | +| `MISS` | Not in cache — fetched from origin. The next request should be a HIT. | +| `EXPIRED` | Found in cache but past its TTL — revalidated against origin. | +| `STALE` | Served from cache while revalidating in the background (`stale-while-revalidate`). | +| `BYPASS` | Caching explicitly skipped, e.g. a `no-cache` rule or cookie. | +| `DYNAMIC` | Not eligible for caching (often HTML or API responses). | +| `UNKNOWN` | No cache headers detected — there may be no CDN at all. | + +The vendor headers behind it: + +| CDN | Header | Example | +| --- | --- | --- | +| Cloudflare | `cf-cache-status` | `HIT` | +| Amazon CloudFront | `x-cache` | `Hit from cloudfront` | +| Fastly / Varnish | `x-cache` | `HIT, MISS` | +| Akamai | `x-cache` | `TCP_HIT` | +| Vercel | `x-vercel-cache` | `STALE` | +| RFC 9211 (Netlify, …) | `cache-status` | `"Netlify Edge"; hit` | +| Generic | `age` + `cache-control` | `age: 842` | + +### Anycast vs unicast + +If every region receives a response from the same edge IP, the CDN is using **anycast**: one IP announced from all locations, routed by the network to the nearest edge (Cloudflare, Fastly). If regions resolve to different IPs, the CDN uses **unicast/GeoDNS**: DNS decides per region (CloudFront, Akamai). Neither is wrong — anycast tends to fail over faster, GeoDNS gives finer routing control — but knowing which one you run matters when debugging regional anomalies. + +### Check cache headers from your terminal + +For a quick single-location check, inspect the headers with cURL: + +```bash +curl -sI https://example.com/asset.js | grep -iE 'cf-cache-status|x-cache|x-vercel-cache|age|cache-control' +``` + +Need a more complex request? Build it with the [cURL Builder](/play/curl). Want latency instead of cache state? Use the [Global Speed Checker](/play/checker). + +--- + +With OpenStatus, you can: + +- Verify your CDN serves cache HITs from every region after a config change. +- Debug `cf-cache-status: MISS` and origin load spikes region by region. +- Detect which CDN a website uses and which edge PoP answered. +- Monitor cache status continuously with header assertions and get alerted when caching breaks. + +If you'd like to request additional test regions or providers, feel free to contact us at [ping@openstatus.dev](mailto:ping@openstatus.dev). diff --git a/apps/web/src/lib/cdn-checker/dev-probe.ts b/apps/web/src/lib/cdn-checker/dev-probe.ts new file mode 100644 index 00000000..939659f2 --- /dev/null +++ b/apps/web/src/lib/cdn-checker/dev-probe.ts @@ -0,0 +1,73 @@ +import type { Region } from "@openstatus/db/src/schema/constants"; + +import type { Timing } from "@/lib/checker/utils"; +import { wait } from "@/lib/utils"; + +import { PROBE_TIMEOUT_MS, mapCheckToCdnResult } from "./probe"; +import type { CdnRegionResponse } from "./schema"; + +function makeTiming(ttfb: number, total: number): Timing { + return { + dnsStart: 0, + dnsDone: 0, + connectStart: 0, + connectDone: 0, + tlsHandshakeStart: 0, + tlsHandshakeDone: 0, + firstByteStart: 0, + firstByteDone: ttfb, + transferStart: ttfb, + transferDone: total, + }; +} + +// dev runs without CRON_SECRET so the checker fleet is unreachable: probe the +// URL directly from the local machine instead. Headers and cache status are +// real; the vantage point is not — every "region" sees the same nearest edge. +export async function devProbeCdnRegion({ + url, + region, +}: { + url: string; + region: Region; +}): Promise { + // jitter so rows stream progressively like the real fan-out + await wait(50 + Math.random() * 800); + + try { + const start = performance.now(); + const response = await fetch(url, { + method: "GET", + redirect: "follow", + cache: "no-store", + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + const ttfb = Math.round(performance.now() - start); + const body = await response.arrayBuffer(); + const total = Math.round(performance.now() - start); + + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + // fetch strips content-length on decompressed responses + headers["content-length"] ??= String(body.byteLength); + + return mapCheckToCdnResult({ + type: "http", + state: "success", + region, + status: response.status, + latency: total, + timestamp: Date.now(), + timing: makeTiming(ttfb, total), + headers, + }); + } catch (error) { + return { + state: "error", + region, + message: error instanceof Error ? error.message : "Request failed", + }; + } +} diff --git a/apps/web/src/lib/cdn-checker/guards.ts b/apps/web/src/lib/cdn-checker/guards.ts new file mode 100644 index 00000000..1c758a8b --- /dev/null +++ b/apps/web/src/lib/cdn-checker/guards.ts @@ -0,0 +1,47 @@ +import { assertSafeUrlSync } from "@openstatus/utils"; + +type UrlGuardResult = + | { ok: true } + | { ok: false; error: string; status: number }; + +export function validateCdnUrl(url: string): UrlGuardResult { + try { + // protocol check + private/loopback/metadata-host block + assertSafeUrlSync(url); + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Invalid URL", + status: 400, + }; + } + + const urlObject = new URL(url); + const hostname = urlObject.hostname.toLowerCase(); + if ( + (hostname === "openstatus.dev" || hostname.endsWith(".openstatus.dev")) && + urlObject.pathname.startsWith("/play/cdn-checker/api") + ) { + return { ok: false, error: "Self-requests are not allowed", status: 400 }; + } + + const blacklistPatterns = (process.env.BLACKLIST_URL ?? "") + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + for (const pattern of blacklistPatterns) { + let matches = false; + try { + matches = new RegExp(pattern).test(url); + } catch (error) { + // skip the bad pattern so it can't silently disable the whole guard + console.error("Invalid blacklist pattern", pattern, error); + continue; + } + if (matches) { + return { ok: false, error: "This URL is not allowed", status: 403 }; + } + } + + return { ok: true }; +} diff --git a/apps/web/src/lib/cdn-checker/probe.test.ts b/apps/web/src/lib/cdn-checker/probe.test.ts new file mode 100644 index 00000000..1871d007 --- /dev/null +++ b/apps/web/src/lib/cdn-checker/probe.test.ts @@ -0,0 +1,142 @@ +import { expect } from "@std/expect"; +import { afterEach, describe, test } from "@std/testing/bdd"; + +import type { RegionCheckerResponse } from "@/lib/checker/utils"; + +import { mapCheckToCdnResult, probeCdnRegion } from "./probe"; + +const timing = { + dnsStart: 0, + dnsDone: 2, + connectStart: 2, + connectDone: 3, + tlsHandshakeStart: 3, + tlsHandshakeDone: 8, + firstByteStart: 8, + firstByteDone: 46, + transferStart: 46, + transferDone: 49, +}; + +describe("mapCheckToCdnResult", () => { + test("maps a cloudflare HIT", () => { + const check: RegionCheckerResponse = { + type: "http", + state: "success", + region: "iad", + status: 200, + latency: 71, + timestamp: 0, + timing, + headers: { + "Cf-Cache-Status": "HIT", + "Cf-Ray": "8c9a1b2c3d4e5f6a-IAD", + Server: "cloudflare", + Age: "842", + "Cache-Control": "public, max-age=3600", + Etag: 'W/"abc123"', + "Content-Length": "18233", + }, + }; + const result = mapCheckToCdnResult(check); + if (result.state !== "success") throw new Error("expected success"); + expect(result.cacheStatus).toBe("HIT"); + expect(result.cacheStatusRaw).toBe("cf-cache-status: HIT"); + expect(result.cdn).toBe("cloudflare"); + expect(result.edgePop).toBe("IAD"); + expect(result.ttfbMs).toBe(38); + expect(result.totalMs).toBe(71); + expect(result.responseSize).toBe(18233); + expect(result.age).toBe(842); + expect(result.etag).toBe('W/"abc123"'); + expect(result.edgeIp).toBeNull(); + }); + + test("falls back to body byte length without content-length", () => { + const check: RegionCheckerResponse = { + type: "http", + state: "success", + region: "ams", + status: 200, + latency: 100, + timestamp: 0, + timing, + headers: {}, + body: "hello", + }; + const result = mapCheckToCdnResult(check); + if (result.state !== "success") throw new Error("expected success"); + expect(result.responseSize).toBe(5); + expect(result.cacheStatus).toBe("UNKNOWN"); + expect(result.cdn).toBeNull(); + }); + + test("passes error responses through", () => { + const check: RegionCheckerResponse = { + state: "error", + region: "syd", + message: "connection refused", + }; + const result = mapCheckToCdnResult(check); + expect(result).toEqual({ + state: "error", + region: "syd", + message: "connection refused", + }); + }); +}); + +describe("probeCdnRegion abort propagation", () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("forwards an abort signal to the underlying fetch", async () => { + let received: AbortSignal | undefined; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + received = init?.signal ?? undefined; + return new Response( + JSON.stringify({ + type: "http", + state: "success", + status: 200, + latency: 10, + headers: { "Cf-Cache-Status": "HIT", "Cf-Ray": "abc-FRA" }, + timestamp: 0, + timing, + }), + { status: 200 }, + ); + }) as typeof fetch; + + const result = await probeCdnRegion({ + url: "https://example.com", + region: "fra", + }); + expect(result.state).toBe("success"); + expect(received).toBeInstanceOf(AbortSignal); + }); + + test("aborts the fetch when the timeout elapses and returns a Timeout row", async () => { + globalThis.fetch = ((_input: unknown, init?: RequestInit) => { + // resolve only when the signal aborts, mirroring a hung request + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(init.signal?.reason ?? new Error("aborted")), + ); + }); + }) as typeof fetch; + + const result = await probeCdnRegion({ + url: "https://example.com", + region: "fra", + timeoutMs: 20, + }); + expect(result).toEqual({ + state: "error", + region: "fra", + message: "Timeout", + }); + }); +}); diff --git a/apps/web/src/lib/cdn-checker/probe.ts b/apps/web/src/lib/cdn-checker/probe.ts new file mode 100644 index 00000000..16ef08f6 --- /dev/null +++ b/apps/web/src/lib/cdn-checker/probe.ts @@ -0,0 +1,92 @@ +import type { Region } from "@openstatus/db/src/schema/constants"; +import { + detectCdn, + extractEdgePop, + getHeader, + normalizeCacheStatus, +} from "@openstatus/header-analysis"; + +import { + type RegionCheckerResponse, + checkRegion, + getTimingPhases, +} from "@/lib/checker/utils"; + +import type { CdnRegionResponse } from "./schema"; + +// the checker downloads the full body before responding; capping after +// headers needs a Go-side change, so keep a generous per-region budget +export const PROBE_TIMEOUT_MS = 12_000; + +export function mapCheckToCdnResult( + check: RegionCheckerResponse, +): CdnRegionResponse { + if (check.state === "error") { + return { state: "error", region: check.region, message: check.message }; + } + + const { headers } = check; + const cache = normalizeCacheStatus(headers); + const { provider } = detectCdn(headers); + const { pop, location } = extractEdgePop(headers, provider); + + const contentLength = getHeader(headers, "content-length"); + const responseSize = contentLength + ? Number.parseInt(contentLength, 10) + : check.body + ? new TextEncoder().encode(check.body).length + : null; + + const age = getHeader(headers, "age"); + const parsedAge = age ? Number.parseInt(age, 10) : Number.NaN; + + return { + state: "success", + region: check.region, + cacheStatus: cache.status, + cacheStatusRaw: cache.source ? `${cache.source}: ${cache.raw}` : null, + edgeIp: null, // requires a checker-side change to capture; see plan phase 5 + edgePop: pop, + edgePopLocation: location, + ttfbMs: Math.round(getTimingPhases(check.timing).ttfb), + totalMs: Math.round(check.latency), + statusCode: check.status, + responseSize: Number.isNaN(responseSize ?? Number.NaN) + ? null + : responseSize, + age: Number.isNaN(parsedAge) ? null : parsedAge, + cacheControl: getHeader(headers, "cache-control"), + etag: getHeader(headers, "etag"), + cdn: provider, + }; +} + +export async function probeCdnRegion({ + url, + region, + timeoutMs = PROBE_TIMEOUT_MS, +}: { + url: string; + region: Region; + timeoutMs?: number; +}): Promise { + try { + const check = await checkRegion({ + url, + region, + method: "GET", + // hard abort, not a soft race: the underlying fetch must not keep + // running (and consuming the edge function budget) after timeout + signal: AbortSignal.timeout(timeoutMs), + }); + return mapCheckToCdnResult(check); + } catch (error) { + const message = + error instanceof Error + ? error.name === "TimeoutError" + ? "Timeout" + : error.message + : "Request failed"; + return { state: "error", region, message }; + } +} diff --git a/apps/web/src/lib/cdn-checker/ratelimit.ts b/apps/web/src/lib/cdn-checker/ratelimit.ts new file mode 100644 index 00000000..71c70959 --- /dev/null +++ b/apps/web/src/lib/cdn-checker/ratelimit.ts @@ -0,0 +1,47 @@ +export const RATE_LIMIT_WINDOW = 60; +export const MAX_REQUESTS_PER_WINDOW = 3; + +type CdnRateLimit = + | { status: "skipped" } + | { status: "no-client-ip" } + | { + status: "ok" | "limited"; + limit: number; + remaining: number; + reset: number; + }; + +// dev runs without Upstash credentials and the redis client throws at module +// evaluation, so the limiter is only imported (and enforced) in production +export async function rateLimitCdnRequest( + prefix: string, + headers: Headers, +): Promise { + if (process.env.NODE_ENV !== "production") return { status: "skipped" }; + + const { getClientIP, ratelimit } = await import("@/lib/ratelimit"); + + const clientIP = getClientIP(headers); + if (!clientIP) return { status: "no-client-ip" }; + + const rl = await ratelimit(`${prefix}:${clientIP}`, { + window: RATE_LIMIT_WINDOW, + limit: MAX_REQUESTS_PER_WINDOW, + }); + + return { + status: rl.success ? "ok" : "limited", + limit: rl.limit, + remaining: rl.remaining, + reset: rl.reset, + }; +} + +export function rateLimitHeaders(rl: CdnRateLimit): Record { + if (rl.status !== "ok" && rl.status !== "limited") return {}; + return { + "X-RateLimit-Limit": rl.limit.toString(), + "X-RateLimit-Remaining": rl.remaining.toString(), + "X-RateLimit-Reset": rl.reset.toString(), + }; +} diff --git a/apps/web/src/lib/cdn-checker/schema.ts b/apps/web/src/lib/cdn-checker/schema.ts new file mode 100644 index 00000000..6bd00693 --- /dev/null +++ b/apps/web/src/lib/cdn-checker/schema.ts @@ -0,0 +1,54 @@ +import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; +import { CACHE_STATUSES, CDN_PROVIDERS } from "@openstatus/header-analysis"; +import { z } from "zod"; + +const cacheStatusSchema = z.enum(CACHE_STATUSES); +const cdnProviderSchema = z.enum(CDN_PROVIDERS); + +export const cdnRegionResultSchema = z.object({ + state: z.literal("success").prefault("success"), + region: monitorRegionSchema, + cacheStatus: cacheStatusSchema, + /** `header-name: value` pair that determined the status */ + cacheStatusRaw: z.string().nullable(), + edgeIp: z.string().nullable(), + edgePop: z.string().nullable(), + edgePopLocation: z.string().nullable(), + ttfbMs: z.number(), + totalMs: z.number(), + statusCode: z.number(), + responseSize: z.number().nullable(), + age: z.number().nullable(), + cacheControl: z.string().nullable(), + etag: z.string().nullable(), + cdn: cdnProviderSchema.nullable(), +}); + +const cdnRegionErrorSchema = z.object({ + state: z.literal("error"), + region: monitorRegionSchema, + message: z.string(), +}); + +export const cdnRegionResponseSchema = z.discriminatedUnion("state", [ + cdnRegionResultSchema, + cdnRegionErrorSchema, +]); + +export const cdnSummarySchema = z.object({ + type: z.literal("summary").prefault("summary"), + totalRegions: z.number(), + respondedRegions: z.number(), + cachedRegions: z.number(), + uncachedRegions: monitorRegionSchema.array(), + unreachableRegions: monitorRegionSchema.array(), + cdn: cdnProviderSchema.nullable(), + /** more than one provider detected across regions (multi-CDN or mixed setup) */ + mixedCdn: z.boolean(), + topology: z.enum(["anycast", "unicast", "unknown"]), + topologyBasis: z.enum(["edge-ips", "provider"]).nullable(), +}); + +export type CdnRegionResult = z.infer; +export type CdnRegionResponse = z.infer; +export type CdnSummary = z.infer; diff --git a/apps/web/src/lib/cdn-checker/summary.test.ts b/apps/web/src/lib/cdn-checker/summary.test.ts new file mode 100644 index 00000000..51401c76 --- /dev/null +++ b/apps/web/src/lib/cdn-checker/summary.test.ts @@ -0,0 +1,100 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import type { CdnRegionResponse, CdnRegionResult } from "./schema"; +import { computeCdnSummary } from "./summary"; + +function makeRow(overrides: Partial = {}): CdnRegionResult { + return { + state: "success", + region: "iad", + cacheStatus: "HIT", + cacheStatusRaw: "cf-cache-status: HIT", + edgeIp: null, + edgePop: "IAD", + edgePopLocation: "Ashburn, USA", + ttfbMs: 38, + totalMs: 71, + statusCode: 200, + responseSize: 18233, + age: 842, + cacheControl: "public, max-age=3600", + etag: 'W/"abc123"', + cdn: "cloudflare", + ...overrides, + }; +} + +describe("computeCdnSummary", () => { + test("counts HIT and STALE as cached, lists the rest as uncached", () => { + const rows: CdnRegionResponse[] = [ + makeRow({ region: "iad", cacheStatus: "HIT" }), + makeRow({ region: "ams", cacheStatus: "STALE" }), + makeRow({ region: "gru", cacheStatus: "MISS" }), + makeRow({ region: "syd", cacheStatus: "EXPIRED" }), + ]; + const summary = computeCdnSummary(rows); + expect(summary.cachedRegions).toBe(2); + expect(summary.respondedRegions).toBe(4); + expect(summary.uncachedRegions).toEqual(["gru", "syd"]); + }); + + test("error rows count as unreachable, not uncached", () => { + const rows: CdnRegionResponse[] = [ + makeRow({ region: "iad" }), + { state: "error", region: "syd", message: "Timeout" }, + ]; + const summary = computeCdnSummary(rows); + expect(summary.totalRegions).toBe(2); + expect(summary.respondedRegions).toBe(1); + expect(summary.unreachableRegions).toEqual(["syd"]); + expect(summary.uncachedRegions).toEqual([]); + }); + + test("majority provider wins, mixedCdn flags disagreement", () => { + const rows: CdnRegionResponse[] = [ + makeRow({ region: "iad", cdn: "cloudflare" }), + makeRow({ region: "ams", cdn: "cloudflare" }), + makeRow({ region: "syd", cdn: "fastly" }), + ]; + const summary = computeCdnSummary(rows); + expect(summary.cdn).toBe("cloudflare"); + expect(summary.mixedCdn).toBe(true); + }); + + test("tied providers -> null cdn instead of an arbitrary winner", () => { + const rows: CdnRegionResponse[] = [ + makeRow({ region: "iad", cdn: "cloudflare" }), + makeRow({ region: "syd", cdn: "fastly" }), + ]; + const summary = computeCdnSummary(rows); + expect(summary.cdn).toBeNull(); + expect(summary.mixedCdn).toBe(true); + }); + + test("no provider detected -> null cdn, unknown topology", () => { + const rows: CdnRegionResponse[] = [makeRow({ cdn: null })]; + const summary = computeCdnSummary(rows); + expect(summary.cdn).toBeNull(); + expect(summary.mixedCdn).toBe(false); + expect(summary.topology).toBe("unknown"); + expect(summary.topologyBasis).toBeNull(); + }); + + test("topology falls back to provider heuristic without edge IPs", () => { + const rows: CdnRegionResponse[] = [ + makeRow({ region: "iad" }), + makeRow({ region: "ams" }), + ]; + const summary = computeCdnSummary(rows); + expect(summary.topology).toBe("anycast"); + expect(summary.topologyBasis).toBe("provider"); + }); + + test("empty input", () => { + const summary = computeCdnSummary([]); + expect(summary.totalRegions).toBe(0); + expect(summary.cachedRegions).toBe(0); + expect(summary.cdn).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/cdn-checker/summary.ts b/apps/web/src/lib/cdn-checker/summary.ts new file mode 100644 index 00000000..e86a5724 --- /dev/null +++ b/apps/web/src/lib/cdn-checker/summary.ts @@ -0,0 +1,44 @@ +import { type CdnProvider, inferTopology } from "@openstatus/header-analysis"; + +import type { CdnRegionResponse, CdnSummary } from "./schema"; + +// STALE counts as cached: the response was served from the edge cache +// (stale-while-revalidate), not from origin +const CACHED_STATUSES = new Set(["HIT", "STALE"]); + +export function computeCdnSummary(rows: CdnRegionResponse[]): CdnSummary { + const responded = rows.filter((row) => row.state === "success"); + const unreachable = rows.filter((row) => row.state === "error"); + + const cached = responded.filter((row) => + CACHED_STATUSES.has(row.cacheStatus), + ); + const uncached = responded.filter( + (row) => !CACHED_STATUSES.has(row.cacheStatus), + ); + + const providerCounts = new Map(); + for (const row of responded) { + if (!row.cdn) continue; + providerCounts.set(row.cdn, (providerCounts.get(row.cdn) ?? 0) + 1); + } + const ranked = [...providerCounts.entries()].sort((a, b) => b[1] - a[1]); + // a tie means no true majority: report null instead of an arbitrary winner + const hasTie = ranked.length > 1 && ranked[0][1] === ranked[1][1]; + const cdn = ranked[0] && !hasTie ? ranked[0][0] : null; + + const topology = inferTopology(responded, cdn); + + return { + type: "summary", + totalRegions: rows.length, + respondedRegions: responded.length, + cachedRegions: cached.length, + uncachedRegions: uncached.map((row) => row.region), + unreachableRegions: unreachable.map((row) => row.region), + cdn, + mixedCdn: providerCounts.size > 1, + topology: topology.topology, + topologyBasis: topology.basis, + }; +} diff --git a/apps/web/src/lib/checker/utils.ts b/apps/web/src/lib/checker/utils.ts index 8b3eed94..72ca2ff7 100644 --- a/apps/web/src/lib/checker/utils.ts +++ b/apps/web/src/lib/checker/utils.ts @@ -175,6 +175,7 @@ type CheckRegionRequest = { method?: Method; headers?: { value: string; key: string }[]; body?: string; + signal?: AbortSignal; }; // ============================================================================ @@ -184,7 +185,7 @@ type CheckRegionRequest = { export async function checkRegion( props: CheckRegionRequest, ): Promise { - const { url, region, method, headers, body } = props; + const { url, region, method, headers, body, signal } = props; const regionInfo = regionDict[region]; let endpoint = ""; @@ -227,6 +228,7 @@ export async function checkRegion( ), body: body ? body : undefined, }), + signal, next: { revalidate: 0 }, }); diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index e28573eb..220b61b9 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -204,4 +204,8 @@ export const Events = { name: "mcp_health_check", channel: "checker", }, + CdnChecker: { + name: "cdn_checker", + channel: "checker", + }, } as const satisfies Record; diff --git a/packages/header-analysis/src/cdn/detect-cdn.test.ts b/packages/header-analysis/src/cdn/detect-cdn.test.ts new file mode 100644 index 00000000..7d7def9e --- /dev/null +++ b/packages/header-analysis/src/cdn/detect-cdn.test.ts @@ -0,0 +1,108 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { detectCdn } from "./detect-cdn"; + +describe("detectCdn", () => { + test("cloudflare via cf-ray", () => { + const result = detectCdn({ + "Cf-Ray": "8c9a1b2c3d4e5f6a-FRA", + Server: "cloudflare", + }); + expect(result.provider).toBe("cloudflare"); + expect(result.evidence).toContain("cf-ray"); + }); + + test("cloudfront via x-amz-cf-id", () => { + const result = detectCdn({ + "X-Amz-Cf-Id": "abc123", + "X-Amz-Cf-Pop": "FRA56-P5", + Via: "1.1 abc.cloudfront.net (CloudFront)", + }); + expect(result.provider).toBe("cloudfront"); + }); + + test("fastly via x-served-by corroborated by x-cache", () => { + const result = detectCdn({ + "X-Served-By": "cache-fra-etou8220141-FRA", + "X-Cache": "HIT", + }); + expect(result.provider).toBe("fastly"); + }); + + test("fastly via x-fastly-request-id alone", () => { + const result = detectCdn({ "X-Fastly-Request-Id": "abc123" }); + expect(result.provider).toBe("fastly"); + }); + + test("bare `x-served-by: cache-` is not fastly (generic varnish prefix)", () => { + const result = detectCdn({ "X-Served-By": "cache-mia-kmia1234-MIA" }); + expect(result.provider).toBeNull(); + expect(result.evidence).toEqual([]); + }); + + test("bare `x-cache` is not fastly (shared cache header)", () => { + const result = detectCdn({ "X-Cache": "HIT" }); + expect(result.provider).toBeNull(); + expect(result.evidence).toEqual([]); + }); + + test("akamai via x-check-cacheable", () => { + const result = detectCdn({ + "X-Check-Cacheable": "YES", + "X-Cache": "TCP_HIT", + }); + expect(result.provider).toBe("akamai"); + }); + + test("vercel via x-vercel-id", () => { + const result = detectCdn({ + "X-Vercel-Id": "fra1::82mqm-1724415466843-d608bd28fa1c", + Server: "Vercel", + }); + expect(result.provider).toBe("vercel"); + }); + + test("bunny via server header", () => { + const result = detectCdn({ Server: "BunnyCDN-DE1-1042" }); + expect(result.provider).toBe("bunny"); + }); + + test("netlify via x-nf-request-id", () => { + const result = detectCdn({ "X-Nf-Request-Id": "abc" }); + expect(result.provider).toBe("netlify"); + }); + + test("google cloud cdn: via corroborated by x-goog-cache-status", () => { + const result = detectCdn({ + Via: "1.1 google", + "X-Goog-Cache-Status": "hit", + }); + expect(result.provider).toBe("google"); + expect(result.evidence).toContain("x-goog-cache-status"); + }); + + test("bare `via: 1.1 google` is not a CDN (load balancer / translate proxy)", () => { + const result = detectCdn({ Via: "1.1 google" }); + expect(result.provider).toBeNull(); + expect(result.evidence).toEqual([]); + }); + + test("outermost proxy wins when stacked (cloudflare in front of vercel)", () => { + const result = detectCdn({ + "Cf-Ray": "8c9a1b2c3d4e5f6a-FRA", + "X-Vercel-Id": "fra1::82mqm", + "X-Vercel-Cache": "HIT", + }); + expect(result.provider).toBe("cloudflare"); + }); + + test("no CDN headers -> null", () => { + const result = detectCdn({ + "Content-Type": "text/html", + Server: "nginx/1.25", + }); + expect(result.provider).toBeNull(); + expect(result.evidence).toEqual([]); + }); +}); diff --git a/packages/header-analysis/src/cdn/detect-cdn.ts b/packages/header-analysis/src/cdn/detect-cdn.ts new file mode 100644 index 00000000..383874a9 --- /dev/null +++ b/packages/header-analysis/src/cdn/detect-cdn.ts @@ -0,0 +1,166 @@ +import { getHeader } from "./get-header"; + +export const CDN_PROVIDERS = [ + "cloudflare", + "cloudfront", + "fastly", + "akamai", + "vercel", + "bunny", + "netlify", + "keycdn", + "imperva", + "sucuri", + "azure-front-door", + "google", +] as const; + +export type CdnProvider = (typeof CDN_PROVIDERS)[number]; + +export const CDN_LABELS: Record = { + cloudflare: "Cloudflare", + cloudfront: "Amazon CloudFront", + fastly: "Fastly", + akamai: "Akamai", + vercel: "Vercel", + bunny: "Bunny CDN", + netlify: "Netlify", + keycdn: "KeyCDN", + imperva: "Imperva", + sucuri: "Sucuri", + "azure-front-door": "Azure Front Door", + google: "Google Cloud CDN", +}; + +export interface CdnDetection { + provider: CdnProvider | null; + /** header names (or `header: value` pairs) that matched */ + evidence: string[]; +} + +type Fingerprint = { + provider: CdnProvider; + headers?: string[]; + /** presence-only signals too generic to be strong (e.g. `x-cache`) */ + broadHeaders?: string[]; + /** substring match against the given header's value (lowercased) */ + contains?: { + header: string; + value: string; + /** + * broad signal shared with non-CDN infra (e.g. `via: 1.1 google` is also + * sent by Google's load balancer/translate proxy): never matches on its + * own — needs a strong signal or a second broad signal to corroborate + */ + broad?: boolean; + }[]; +}; + +// ordered outermost-proxy first: when stacked (e.g. Cloudflare in front of +// Vercel) the first match is the network that actually served the client +const FINGERPRINTS: Fingerprint[] = [ + { + provider: "cloudflare", + headers: ["cf-ray", "cf-cache-status"], + contains: [{ header: "server", value: "cloudflare" }], + }, + { + provider: "akamai", + headers: [ + "x-akamai-request-id", + "x-akamai-transformed", + "x-check-cacheable", + ], + contains: [{ header: "server", value: "akamaighost" }], + }, + { + provider: "imperva", + headers: ["x-iinfo"], + contains: [{ header: "x-cdn", value: "incapsula" }], + }, + { + provider: "sucuri", + headers: ["x-sucuri-id", "x-sucuri-cache"], + }, + { + provider: "fastly", + headers: ["x-fastly-request-id", "fastly-debug-digest"], + // `x-served-by: cache-` is a generic Varnish prefix and `x-cache`/`x-timer` + // are shared cache headers: any one alone must not claim Fastly + broadHeaders: ["x-cache", "x-timer"], + contains: [ + { header: "x-served-by", value: "cache-", broad: true }, + { header: "via", value: "fastly" }, + ], + }, + { + provider: "cloudfront", + headers: ["x-amz-cf-id", "x-amz-cf-pop"], + contains: [{ header: "via", value: "cloudfront" }], + }, + { + provider: "bunny", + headers: ["cdn-pullzone", "cdn-requestid"], + contains: [{ header: "server", value: "bunnycdn" }], + }, + { + provider: "keycdn", + contains: [{ header: "server", value: "keycdn" }], + }, + { + provider: "azure-front-door", + headers: ["x-azure-ref"], + }, + { + provider: "google", + headers: ["x-goog-cache-status", "x-google-cache-control"], + contains: [{ header: "via", value: "1.1 google", broad: true }], + }, + { + provider: "netlify", + headers: ["x-nf-request-id"], + contains: [{ header: "server", value: "netlify" }], + }, + { + provider: "vercel", + headers: ["x-vercel-id", "x-vercel-cache"], + contains: [{ header: "server", value: "vercel" }], + }, +]; + +export function detectCdn(headers: Record): CdnDetection { + for (const fingerprint of FINGERPRINTS) { + const evidence: string[] = []; + // a single broad signal (e.g. `via: 1.1 google`, `x-served-by: cache-`) + // can't match alone — require a strong signal or two broad ones agreeing + let strongMatches = 0; + let broadMatches = 0; + + for (const name of fingerprint.headers ?? []) { + if (getHeader(headers, name)) { + evidence.push(name); + strongMatches++; + } + } + for (const name of fingerprint.broadHeaders ?? []) { + if (getHeader(headers, name)) { + evidence.push(name); + broadMatches++; + } + } + for (const { header, value, broad } of fingerprint.contains ?? []) { + const actual = getHeader(headers, header); + if (actual?.toLowerCase().includes(value)) { + evidence.push(`${header}: ${actual}`); + if (broad) broadMatches++; + else strongMatches++; + } + } + + if (strongMatches > 0 || broadMatches >= 2) { + return { provider: fingerprint.provider, evidence }; + } + } + + return { provider: null, evidence: [] }; +} diff --git a/packages/header-analysis/src/cdn/edge-pop.test.ts b/packages/header-analysis/src/cdn/edge-pop.test.ts new file mode 100644 index 00000000..bbec53c1 --- /dev/null +++ b/packages/header-analysis/src/cdn/edge-pop.test.ts @@ -0,0 +1,56 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { extractEdgePop } from "./edge-pop"; + +describe("extractEdgePop", () => { + test("cloudflare colo from cf-ray", () => { + const result = extractEdgePop( + { "Cf-Ray": "8c9a1b2c3d4e5f6a-FRA" }, + "cloudflare", + ); + expect(result.pop).toBe("FRA"); + expect(result.location).toContain("Frankfurt"); + }); + + test("cloudfront pop from x-amz-cf-pop", () => { + const result = extractEdgePop({ "X-Amz-Cf-Pop": "FRA56-P5" }, "cloudfront"); + expect(result.pop).toBe("FRA56-P5"); + expect(result.location).toContain("Frankfurt"); + }); + + test("fastly pop from x-served-by", () => { + const result = extractEdgePop( + { "X-Served-By": "cache-fra-etou8220141-FRA" }, + "fastly", + ); + expect(result.pop).toBe("FRA"); + expect(result.location).toContain("Frankfurt"); + }); + + test("vercel edge from x-vercel-id first segment", () => { + const result = extractEdgePop( + { "X-Vercel-Id": "fra1::iad1::82mqm-1724415466843-d608bd28fa1c" }, + "vercel", + ); + expect(result.pop).toBe("fra1"); + expect(result.location).toContain("Frankfurt"); + }); + + test("vercel pops resolve via the vercel region map (not IATA)", () => { + const result = extractEdgePop({ "X-Vercel-Id": "cle1::abc" }, "vercel"); + expect(result.pop).toBe("cle1"); + expect(result.location).toContain("Cleveland"); + }); + + test("unknown provider -> null", () => { + const result = extractEdgePop({ "X-Cache": "HIT" }, null); + expect(result.pop).toBeNull(); + expect(result.location).toBeNull(); + }); + + test("missing header -> null", () => { + const result = extractEdgePop({}, "cloudflare"); + expect(result.pop).toBeNull(); + }); +}); diff --git a/packages/header-analysis/src/cdn/edge-pop.ts b/packages/header-analysis/src/cdn/edge-pop.ts new file mode 100644 index 00000000..f818be0f --- /dev/null +++ b/packages/header-analysis/src/cdn/edge-pop.ts @@ -0,0 +1,68 @@ +import { parseCfRay } from "../parser/cf-ray"; +import { regions as iataRegions } from "../regions/cloudflare"; +import { regions as vercelRegions } from "../regions/vercel"; +import type { CdnProvider } from "./detect-cdn"; +import { getHeader } from "./get-header"; + +export interface EdgePop { + /** PoP/colo code, e.g. "FRA" or "fra1" */ + pop: string | null; + /** best-effort city name resolved from the IATA code */ + location: string | null; +} + +// the cloudflare map is keyed by IATA airport codes, so it doubles as a +// generic IATA → city lookup for other vendors' PoP codes +function lookupIata(code: string): string | null { + return iataRegions[code.toUpperCase()]?.location ?? null; +} + +export function extractEdgePop( + headers: Record, + provider: CdnProvider | null, +): EdgePop { + switch (provider) { + case "cloudflare": { + const ray = getHeader(headers, "cf-ray"); + if (!ray) break; + const parsed = parseCfRay(ray); + if (parsed.status === "success") { + return { pop: parsed.data.code, location: parsed.data.location }; + } + const code = ray.match(/\b([A-Z]{3})\b/)?.[1]; + if (code) return { pop: code, location: lookupIata(code) }; + break; + } + case "cloudfront": { + // e.g. "FRA56-P5" — leading 3 letters are the IATA code + const pop = getHeader(headers, "x-amz-cf-pop"); + const code = pop?.match(/^([A-Z]{3})/)?.[1]; + if (pop && code) return { pop, location: lookupIata(code) }; + break; + } + case "fastly": { + // e.g. "cache-fra-etou8220141-FRA" — trailing segment is the IATA code + const servedBy = getHeader(headers, "x-served-by"); + const code = servedBy?.match(/-([A-Z]{3})$/)?.[1]; + if (code) return { pop: code, location: lookupIata(code) }; + break; + } + case "vercel": { + // e.g. "fra1::iad1::82mqm-..." — first segment is the serving edge + const id = getHeader(headers, "x-vercel-id"); + const pop = id?.match(/^([a-z]{3}\d*)/)?.[1]; + if (pop) { + const known = vercelRegions[pop]; + return { + pop, + location: known?.location ?? lookupIata(pop.slice(0, 3)), + }; + } + break; + } + default: + break; + } + + return { pop: null, location: null }; +} diff --git a/packages/header-analysis/src/cdn/get-header.ts b/packages/header-analysis/src/cdn/get-header.ts new file mode 100644 index 00000000..df118695 --- /dev/null +++ b/packages/header-analysis/src/cdn/get-header.ts @@ -0,0 +1,11 @@ +// Header keys arrive in mixed casing (Go canonicalizes, fetch lowercases). +export function getHeader( + headers: Record, + name: string, +): string | null { + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lower) return value; + } + return null; +} diff --git a/packages/header-analysis/src/cdn/infer-topology.test.ts b/packages/header-analysis/src/cdn/infer-topology.test.ts new file mode 100644 index 00000000..05eeb786 --- /dev/null +++ b/packages/header-analysis/src/cdn/infer-topology.test.ts @@ -0,0 +1,68 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { inferTopology } from "./infer-topology"; + +describe("inferTopology", () => { + test("same IP from many regions -> anycast (measured)", () => { + const rows = [ + { edgeIp: "104.16.0.1" }, + { edgeIp: "104.16.0.1" }, + { edgeIp: "104.16.0.1" }, + ]; + expect(inferTopology(rows, null)).toEqual({ + topology: "anycast", + basis: "edge-ips", + }); + }); + + test("different IPs -> unicast (measured)", () => { + const rows = [{ edgeIp: "1.2.3.4" }, { edgeIp: "5.6.7.8" }]; + expect(inferTopology(rows, null)).toEqual({ + topology: "unicast", + basis: "edge-ips", + }); + }); + + test("single IP but too few vantage points -> falls through", () => { + const rows = [{ edgeIp: "1.2.3.4" }, { edgeIp: null }]; + expect(inferTopology(rows, null).topology).toBe("unknown"); + }); + + test("null-IP rows are not vantage points", () => { + const rows = [ + { edgeIp: "1.2.3.4" }, + { edgeIp: null }, + { edgeIp: null }, + { edgeIp: null }, + ]; + expect(inferTopology(rows, null)).toEqual({ + topology: "unknown", + basis: null, + }); + }); + + test("no IPs, known-anycast provider -> provider heuristic", () => { + const rows = [{ edgeIp: null }, { edgeIp: null }]; + expect(inferTopology(rows, "cloudflare")).toEqual({ + topology: "anycast", + basis: "provider", + }); + expect(inferTopology(rows, "cloudfront")).toEqual({ + topology: "unicast", + basis: "provider", + }); + }); + + test("no IPs, unknown provider -> unknown", () => { + expect(inferTopology([{ edgeIp: null }], null)).toEqual({ + topology: "unknown", + basis: null, + }); + }); + + test("measured IPs win over provider heuristic", () => { + const rows = [{ edgeIp: "1.2.3.4" }, { edgeIp: "5.6.7.8" }]; + expect(inferTopology(rows, "cloudflare").topology).toBe("unicast"); + }); +}); diff --git a/packages/header-analysis/src/cdn/infer-topology.ts b/packages/header-analysis/src/cdn/infer-topology.ts new file mode 100644 index 00000000..a0194e61 --- /dev/null +++ b/packages/header-analysis/src/cdn/infer-topology.ts @@ -0,0 +1,41 @@ +import type { CdnProvider } from "./detect-cdn"; + +export type Topology = "anycast" | "unicast" | "unknown"; + +export interface TopologyInference { + topology: Topology; + /** "edge-ips" is measured; "provider" is a known-architecture heuristic */ + basis: "edge-ips" | "provider" | null; +} + +// networks with a publicly documented anycast (or geo-routed) architecture — +// used only when edge IPs are unavailable +const PROVIDER_TOPOLOGY: Partial> = { + cloudflare: "anycast", + fastly: "anycast", + vercel: "anycast", + cloudfront: "unicast", + akamai: "unicast", +}; + +export function inferTopology( + rows: { edgeIp: string | null }[], + provider: CdnProvider | null, +): TopologyInference { + const measured = rows + .map((row) => row.edgeIp) + .filter((ip): ip is string => Boolean(ip)); + const ips = new Set(measured); + + if (ips.size >= 2) return { topology: "unicast", basis: "edge-ips" }; + // a single IP only signals anycast with enough measured vantage points — + // rows without an edge IP are not evidence + if (ips.size === 1 && measured.length >= 3) { + return { topology: "anycast", basis: "edge-ips" }; + } + + const fromProvider = provider ? PROVIDER_TOPOLOGY[provider] : undefined; + if (fromProvider) return { topology: fromProvider, basis: "provider" }; + + return { topology: "unknown", basis: null }; +} diff --git a/packages/header-analysis/src/cdn/normalize-cache-status.test.ts b/packages/header-analysis/src/cdn/normalize-cache-status.test.ts new file mode 100644 index 00000000..1e736d9e --- /dev/null +++ b/packages/header-analysis/src/cdn/normalize-cache-status.test.ts @@ -0,0 +1,140 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { normalizeCacheStatus } from "./normalize-cache-status"; + +describe("cloudflare", () => { + for (const [raw, status] of [ + ["HIT", "HIT"], + ["MISS", "MISS"], + ["EXPIRED", "EXPIRED"], + ["STALE", "STALE"], + ["UPDATING", "STALE"], + ["REVALIDATED", "HIT"], + ["BYPASS", "BYPASS"], + ["DYNAMIC", "DYNAMIC"], + ] as const) { + test(`cf-cache-status: ${raw} -> ${status}`, () => { + const result = normalizeCacheStatus({ "Cf-Cache-Status": raw }); + expect(result.status).toBe(status); + expect(result.source).toBe("cf-cache-status"); + expect(result.raw).toBe(raw); + }); + } +}); + +describe("vercel", () => { + for (const [raw, status] of [ + ["HIT", "HIT"], + ["MISS", "MISS"], + ["STALE", "STALE"], + ["PRERENDER", "HIT"], + ["REVALIDATED", "HIT"], + ["BYPASS", "BYPASS"], + ] as const) { + test(`x-vercel-cache: ${raw} -> ${status}`, () => { + const result = normalizeCacheStatus({ "X-Vercel-Cache": raw }); + expect(result.status).toBe(status); + expect(result.source).toBe("x-vercel-cache"); + }); + } +}); + +describe("cloudfront / fastly / akamai (x-cache)", () => { + for (const [raw, status] of [ + ["Hit from cloudfront", "HIT"], + ["Miss from cloudfront", "MISS"], + ["RefreshHit from cloudfront", "EXPIRED"], + ["HIT", "HIT"], + ["MISS, HIT", "HIT"], + [ + "TCP_HIT from a23-45-67-89.deploy.akamaitechnologies.com (AkamaiGHost)", + "HIT", + ], + ["TCP_MEM_HIT", "HIT"], + ["TCP_MISS", "MISS"], + ["TCP_REFRESH_HIT", "EXPIRED"], + ["TCP_EXPIRED_MISS", "EXPIRED"], + ] as const) { + test(`x-cache: ${raw} -> ${status}`, () => { + const result = normalizeCacheStatus({ "X-Cache": raw }); + expect(result.status).toBe(status); + expect(result.source).toBe("x-cache"); + }); + } +}); + +describe("rfc 9211 cache-status", () => { + for (const [raw, status] of [ + ['"Netlify Edge"; hit', "HIT"], + ["ExampleCache; fwd=miss; stored", "MISS"], + ["ExampleCache; fwd=stale", "EXPIRED"], + ["ExampleCache; fwd=bypass", "BYPASS"], + ] as const) { + test(`cache-status: ${raw} -> ${status}`, () => { + const result = normalizeCacheStatus({ "Cache-Status": raw }); + expect(result.status).toBe(status); + expect(result.source).toBe("cache-status"); + }); + } +}); + +describe("vendor header priority", () => { + test("cf-cache-status wins over x-cache", () => { + const result = normalizeCacheStatus({ + "Cf-Cache-Status": "HIT", + "X-Cache": "MISS", + }); + expect(result.status).toBe("HIT"); + expect(result.source).toBe("cf-cache-status"); + }); +}); + +describe("generic fallback", () => { + test("age > 0 with cacheable cache-control -> HIT", () => { + const result = normalizeCacheStatus({ + Age: "842", + "Cache-Control": "public, max-age=3600", + }); + expect(result.status).toBe("HIT"); + expect(result.source).toBe("age"); + }); + + test("no-store -> DYNAMIC", () => { + const result = normalizeCacheStatus({ + "Cache-Control": "private, no-cache, no-store, max-age=0", + }); + expect(result.status).toBe("DYNAMIC"); + expect(result.source).toBe("cache-control"); + }); + + test("mixed-case directives are matched (RFC 9111)", () => { + const result = normalizeCacheStatus({ + "Cache-Control": "Private, No-Store", + }); + expect(result.status).toBe("DYNAMIC"); + }); + + test("age 0 with cacheable cache-control -> UNKNOWN", () => { + const result = normalizeCacheStatus({ + Age: "0", + "Cache-Control": "public, max-age=3600", + }); + expect(result.status).toBe("UNKNOWN"); + }); + + test("no caching headers at all -> UNKNOWN", () => { + const result = normalizeCacheStatus({ + "Content-Type": "text/html", + Server: "nginx", + }); + expect(result.status).toBe("UNKNOWN"); + expect(result.raw).toBeNull(); + expect(result.source).toBeNull(); + }); + + test("lowercase header keys are matched", () => { + const result = normalizeCacheStatus({ "cf-cache-status": "HIT" }); + expect(result.status).toBe("HIT"); + }); +}); diff --git a/packages/header-analysis/src/cdn/normalize-cache-status.ts b/packages/header-analysis/src/cdn/normalize-cache-status.ts new file mode 100644 index 00000000..54a5c923 --- /dev/null +++ b/packages/header-analysis/src/cdn/normalize-cache-status.ts @@ -0,0 +1,91 @@ +import { parseCacheControlHeader } from "../parser/cache-control"; +import { getHeader } from "./get-header"; + +export const CACHE_STATUSES = [ + "HIT", + "MISS", + "EXPIRED", + "STALE", + "BYPASS", + "DYNAMIC", + "UNKNOWN", +] as const; + +export type CacheStatus = (typeof CACHE_STATUSES)[number]; + +export interface NormalizedCacheStatus { + status: CacheStatus; + /** original header value, e.g. "Hit from cloudfront" */ + raw: string | null; + /** header name that determined the status, e.g. "cf-cache-status" */ + source: string | null; +} + +function fromToken(value: string): CacheStatus | null { + const token = value.toUpperCase(); + // order matters: "REFRESH_HIT" and "EXPIRED_MISS" must not match plain HIT/MISS + if (token.includes("STALE")) return "STALE"; + if (token.includes("UPDATING")) return "STALE"; + if (token.includes("EXPIRED")) return "EXPIRED"; + if (token.includes("REFRESH")) return "EXPIRED"; + if (token.includes("REVALIDATED")) return "HIT"; + if (token.includes("PRERENDER")) return "HIT"; + if (token.includes("BYPASS")) return "BYPASS"; + if (token.includes("DYNAMIC")) return "DYNAMIC"; + if (token.includes("HIT")) return "HIT"; + if (token.includes("MISS")) return "MISS"; + if (token.includes("PASS")) return "BYPASS"; + return null; +} + +// RFC 9211, e.g. `"Netlify Edge"; hit` or `ExampleCache; fwd=miss; stored` +function fromCacheStatusHeader(value: string): CacheStatus | null { + const lower = value.toLowerCase(); + if (/;\s*hit/.test(lower)) return "HIT"; + const fwd = lower.match(/fwd=([a-z-]+)/)?.[1]; + if (!fwd) return null; + if (fwd === "bypass") return "BYPASS"; + if (fwd === "stale") return "EXPIRED"; + return "MISS"; +} + +const VENDOR_HEADERS = [ + "cf-cache-status", + "x-vercel-cache", + "cache-status", + "x-cache", + "x-cache-status", + "cdn-cache", + "x-cache-lookup", +] as const; + +export function normalizeCacheStatus( + headers: Record, +): NormalizedCacheStatus { + for (const name of VENDOR_HEADERS) { + const value = getHeader(headers, name); + if (!value) continue; + const status = + name === "cache-status" ? fromCacheStatusHeader(value) : fromToken(value); + if (status) return { status, raw: value, source: name }; + } + + // no vendor header: infer from standard caching headers + const age = getHeader(headers, "age"); + const cacheControl = getHeader(headers, "cache-control"); + + if (cacheControl) { + const directives = parseCacheControlHeader(cacheControl); + // directive names are case-insensitive per RFC 9111 + const has = (name: string) => + directives.some((d) => d.name.toLowerCase() === name); + if (has("no-store") || has("private")) { + return { status: "DYNAMIC", raw: cacheControl, source: "cache-control" }; + } + if (age && Number.parseInt(age, 10) > 0) { + return { status: "HIT", raw: age, source: "age" }; + } + } + + return { status: "UNKNOWN", raw: null, source: null }; +} diff --git a/packages/header-analysis/src/index.ts b/packages/header-analysis/src/index.ts index 31f6cd60..e4023189 100644 --- a/packages/header-analysis/src/index.ts +++ b/packages/header-analysis/src/index.ts @@ -4,3 +4,8 @@ export * from "./parser/cf-ray"; export * from "./parser/fly-request-id"; export * from "./parser/x-vercel-cache"; export * from "./parser/x-vercel-id"; +export * from "./cdn/detect-cdn"; +export * from "./cdn/edge-pop"; +export * from "./cdn/get-header"; +export * from "./cdn/infer-topology"; +export * from "./cdn/normalize-cache-status";