diff --git a/docs/operations.md b/docs/operations.md index daebd05ff..6ef698947 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -59,6 +59,61 @@ every failure generated by the documentation Worker or R2. A Workers Observability notification also needs an actual alert rule; the notification destination alone does not monitor errors. +## CDN cache policy + +The Cloudflare zone's Browser Cache TTL is set to **Respect Existing Headers**. +Two active Cache Rules refine that default: + +- `Cache immutable Astro assets` matches `www.gecode.dev/_astro/*`, makes the + response cache-eligible, and sets both edge and browser TTLs to one year. +- `Cache website HTML and redirects` matches the apex host and non-documentation + website HTML paths on `www`. It makes those responses cache-eligible, respects + origin cache headers, and uses Cloudflare's status-code TTL when the origin + sends no cache header. + +Keep `/doc*` out of the website rule. The documentation Worker owns its cache +policy. The prepared Workers Cache change gives selected routes and aliases +thirty days of edge freshness plus seven days of stale-while-revalidate, while +browsers retain one-day freshness. Sitemaps and robots.txt retain five-minute +browser freshness. Explicit revisions retain a one-year +policy. See the [Worker runbook](../workers/docs/README.md#response-caching) for +the policy and staging checks. On 30 September 2026, the change was deployed to +staging from [PR #13](https://github.com/Gecode/gecode.github.io/pull/13). +The updated [staging deployment and smoke checks passed](https://github.com/Gecode/gecode.github.io/actions/runs/36679599037). +A fresh URL returned MISS, HIT, HIT with `Cache-Control: public, max-age=86400`. +An earlier probe confirmed one Worker execution for three requests. The smoke +check accepts native Workers Cache's 206 response to HEAD with Range and checks +plain HEAD separately. Production deployment remains subject to the protected +environment's approval. Short-TTL SWR and live revision rollback have not yet +been exercised against the native cache. + +Ordinary zone caching runs after Worker routing. Workers Cache sits before +execution, but its hits still count as Worker requests. Neither response headers +nor a zone Cache Rule removes that request charge. Measure execution avoidance +separately from billable requests and zone cache hits. + +### Option: a CDN ahead of the Worker + +An external CDN can avoid Cloudflare requests on cache hits. A concrete option +is Fastly Full Site Delivery at `www.gecode.dev`, with documentation misses sent +to a dedicated Cloudflare Worker origin hostname and website misses sent to +GitHub Pages. This is a researched option, not a provisioned or tested service. + +Use Fastly's [Surrogate-Control header](https://www.fastly.com/documentation/reference/http/http-headers/Surrogate-Control/) +for the long edge TTL and SWR policy; Cloudflare's edge-only header does not +configure Fastly. Enable [origin shielding](https://www.fastly.com/documentation/guides/concepts/shielding/) +to share cache fills across locations. Tag mutable documentation responses and +[soft-purge those tags](https://www.fastly.com/documentation/guides/concepts/cache/purging/) +after release promotion or rollback. Deploying the Worker alone cannot +invalidate the external cache. + +Before switching public DNS, verify backend Host/TLS settings, public canonical +URLs and redirects, analytics injection and `/e/` forwarding, query handling, +PDF ranges, SWR, and release invalidation on a preview hostname. Keep private +R2 access through the Worker. Do not point the CDN backend at its own public +hostname or expose the existing bucket's staging and manifest keys. Select the +provider and billing arrangement before provisioning this separate rollout. + ## Email forwarding The email Worker waits for every configured forward to finish and reports any diff --git a/scripts/docs/smoke-worker.mjs b/scripts/docs/smoke-worker.mjs index 328ecb1c6..23274c19e 100644 --- a/scripts/docs/smoke-worker.mjs +++ b/scripts/docs/smoke-worker.mjs @@ -154,7 +154,7 @@ await check(`/doc/${version}/readiness-missing-page.html`, 404); for (const prefix of prefixes) { const pdf = `${prefix}/MPG.pdf`; let etag; - await check(pdf, 200, { method: "HEAD", headers: { Range: "bytes=0-15" } }, (response) => { + await check(pdf, 200, { method: "HEAD" }, (response) => { assert.match(response.headers.get("content-type"), /application\/pdf/); assert(Number(response.headers.get("content-length")) > 16); assert.equal(response.headers.get("content-range"), null); @@ -162,6 +162,13 @@ for (const prefix of prefixes) { etag = response.headers.get("etag"); assert(etag); }); + // Workers Cache applies Range to HEAD as well as GET. + await check(pdf, 206, { method: "HEAD", headers: { Range: "bytes=0-15" } }, async (response) => { + assert.match(response.headers.get("content-range"), /^bytes 0-15\/\d+$/); + assert.equal(response.headers.get("content-length"), "16"); + assertCanonical(response, prefix, "MPG.pdf"); + assert.equal((await response.arrayBuffer()).byteLength, 0); + }); await check(pdf, 206, { headers: { Range: "bytes=0-15", "If-Range": etag } }, async (response) => { assert.match(response.headers.get("content-range"), /^bytes 0-15\/\d+$/); assert.equal(response.headers.get("content-length"), "16"); diff --git a/workers/docs/README.md b/workers/docs/README.md index 0d6fe6a73..f438a956b 100644 --- a/workers/docs/README.md +++ b/workers/docs/README.md @@ -23,7 +23,8 @@ IDs, for example `{"6.4.0":"20260905-rst2"}`. The Worker resolves The explicit `/doc/6.4.0/revisions/20260905-rst2/...` route always addresses that revision, independently of `DOC_REVISIONS`. Verify it before selecting a newly published revision. Only this explicit revision route has a one-year immutable -cache policy; selected version routes and aliases have a five-minute policy. +browser cache policy; selected version routes and aliases have a one-day +browser policy and a thirty-day edge policy. Responses identify both choices with `X-Gecode-Documentation-Version` and `X-Gecode-Documentation-Revision` (the latter is `legacy` for an unselected historical prefix). @@ -41,6 +42,52 @@ origin without documentation indexing headers. Unknown staging paths return 404, avoiding a fetch back into the custom-domain Worker. The `/doc` landing redirect uses `/documentation.html`, which exists before and after Astro. +## Response caching + +The checked-in configuration enables [Workers Cache](https://developers.cloudflare.com/workers/cache/). +Cloudflare checks this cache before executing the Worker and stores the final +response, including indexing headers and analytics injection. The Worker no +longer uses `caches.default`. Cache hits avoid execution and R2 reads, but still +count as billable Worker requests and against the Free request allowance. + +`Cloudflare-CDN-Cache-Control` separates edge freshness from browser freshness: + +| Response | Browser freshness | Edge freshness | Stale while revalidating | +| --- | --- | --- | --- | +| Latest, compatibility alias, selected version, redirects | 1 day | 30 days | 7 days | +| Explicit revision | 1 year, immutable | 1 year | 7 days | +| Selected sitemap and robots.txt | 5 minutes | 1 day | 1 day | +| 404 | No storage | 5 minutes | None | + +Successful responses also allow stale delivery for thirty days on an origin +error. Other errors are not stored. Neighboring website paths bypass Workers +Cache and retain their origin's browser policy. Use `max-age`, not `s-maxage`, +in the edge header: [Workers Cache disables stale serving with `s-maxage`](https://developers.cloudflare.com/workers/cache/configuration/). + +`cross_version_cache: false` isolates each Worker deployment's cache. Promoting +or rolling back a revision requires deploying the changed configuration; the +new deployment does not reuse the previous deployment's cached aliases. Browser +copies can remain fresh for one day after a release or rollback. Immutable R2 objects must never be +overwritten. Cache lifetime is not a retention guarantee: eviction and distinct +query strings can still cause misses. + +Before the first production rollout, validate this configuration on staging: + +Native Workers Cache returns 206 for HEAD requests carrying Range, with range +metadata and no body. This edge behavior is accepted; the deployment smoke +check covers it separately from plain HEAD, which returns full metadata with +status 200. Direct handler tests still expect HEAD to ignore Range. + +1. Check repeated GETs for cache hits and confirm only misses execute the Worker + using Workers Cache metrics and execution logs. Zone cache statistics alone + do not establish the execution avoidance rate. +2. Check cold and warm HEAD, PDF ranges, redirects, canonical headers, sitemaps, + and errors. Local tests call the handler directly, not the pre-Worker cache. +3. Exercise SWR with a temporary short staging TTL, then restore the configured + policy. Verify a deployment changing the selected revision serves new content + and that rollback restores the previous selection. +4. Run the existing smoke checks before promoting through the protected workflow. + ## Local validation Run all Worker integration tests and compile the production configuration: @@ -172,7 +219,7 @@ node scripts/docs/smoke-worker.mjs https://www.gecode.dev 6.4.0 \ For a selected version that is not latest, add `--immutable-only` to skip latest aliases. Revision checks cover the modeling entry page, Pagefind index and runtime assets, reference HTML, sitemap headers, exact PDF ranges, and 404s. -Previously cached selected routes may remain visible for up to five minutes. +Previously browser-cached selected routes may remain visible for up to one day. Rollback restores the previous `DOC_REVISIONS` entry (or removes it to select historical objects), without changing stored documentation. diff --git a/workers/docs/src/index.test.ts b/workers/docs/src/index.test.ts index 8579645d3..9f9469ad2 100644 --- a/workers/docs/src/index.test.ts +++ b/workers/docs/src/index.test.ts @@ -38,7 +38,10 @@ describe("documentation worker", () => { const page = await request("/doc/6.4.0/reference/PageChange.html"); expect(page.status).toBe(200); expect(await page.text()).toBe("0123456789"); - expect(page.headers.get("cache-control")).toBe("public, max-age=300, s-maxage=300"); + expect(page.headers.get("cache-control")).toBe("public, max-age=86400"); + expect(page.headers.get("cloudflare-cdn-cache-control")).toBe( + "public, max-age=2592000, stale-while-revalidate=604800, stale-if-error=2592000", + ); expect(page.headers.get("x-gecode-documentation-revision")).toBe("legacy"); expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8"); expect(page.headers.get("link")).toBeNull(); @@ -71,15 +74,6 @@ describe("documentation worker", () => { expect(await response.text()).not.toContain("/e/init.js"); }); - it("serves repeat requests from the edge cache", async () => { - const url = "/doc/6.4.0/reference/PageChange.html?cache-test=1"; - expect((await request(url)).status).toBe(200); - await env.DOCS.delete("6.4.0/reference/PageChange.html"); - const cached = await request("/doc/6.4.0/reference/PageChange.html?different-query=1"); - expect(cached.status).toBe(200); - expect(await cached.text()).toBe("0123456789"); - }); - it("returns 503 when R2 fails", async () => { const originalGet = env.DOCS.get.bind(env.DOCS); env.DOCS.get = async () => { throw new Error("test outage"); }; @@ -87,6 +81,7 @@ describe("documentation worker", () => { env.DOCS.get = originalGet; expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBe("60"); + expect(response.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); }); it("uses one R2 read for an ordinary cache miss", async () => { @@ -124,7 +119,7 @@ describe("documentation worker", () => { async (path) => { const response = await request(path); expect(await response.text()).toBe("0123456789"); - expect(response.headers.get("cache-control")).toContain("max-age=300"); + expect(response.headers.get("cache-control")).toContain("max-age=86400"); expect(response.headers.get("x-gecode-documentation-version")).toBe("6.4.0"); const canonical = path.startsWith("/doc/latest/"); expect(response.headers.get("x-robots-tag")).toBe(canonical ? null : "noindex"); @@ -161,31 +156,7 @@ describe("documentation worker", () => { } }); - it("applies the current indexing policy even to cached headers", async () => { - const match = vi.spyOn(caches.default, "match").mockImplementation(async () => new Response("cached content", { - headers: { - "Content-Type": "text/html; charset=utf-8", - Link: '; rel="canonical"', - "X-Robots-Tag": "index", - }, - })); - try { - const immutable = await request("/doc/6.4.0/reference/PageChange.html"); - expect(await immutable.text()).toBe("cached content"); - expect(immutable.headers.get("x-robots-tag")).toBe("noindex"); - expect(immutable.headers.get("link")).toBeNull(); - const latest = await request("/doc/latest/reference/PageChange.html"); - expect(await latest.text()).toBe("cached content"); - expect(latest.headers.get("x-robots-tag")).toBeNull(); - expect(latest.headers.get("link")).toBe( - '; rel="canonical"', - ); - } finally { - match.mockRestore(); - } - }); - - it("selects new latest content without reusing the preceding release's cache", async () => { + it("selects new latest content when the release configuration changes", async () => { await request("/doc/latest/reference/PageChange.html"); await env.DOCS.put("7.0.0/reference/PageChange.html", "new release", { httpMetadata: { contentType: "text/html; charset=utf-8" }, @@ -204,14 +175,11 @@ describe("documentation worker", () => { const shardXml = 'https://www.gecode.dev/doc/6.5.0/reference/PageChange.html'; await env.DOCS.put(`${version}/sitemap.xml`, indexXml, { httpMetadata: { contentType: "application/xml" } }); await env.DOCS.put(`${version}/sitemap-1.xml`, shardXml, { httpMetadata: { contentType: "application/xml" } }); - // Entries cached before the indexing-policy change must not leak old URLs. - await caches.default.put(new Request(`${base}/doc/sitemap.xml`), new Response(indexXml, { - headers: { "Cache-Control": "public, max-age=300", "Content-Type": "application/xml" }, - })); for (const path of ["/doc/sitemap.xml", "/doc/latest/sitemap.xml", "/doc-latest/sitemap.xml"]) { const response = await request(path, undefined, version); expect(response.status).toBe(200); expect(await response.text()).toBe(indexXml.replaceAll(`/doc/${version}/`, "/doc/latest/")); + expect(response.headers.get("cloudflare-cdn-cache-control")).toContain("max-age=86400,"); } const shard = await request("/doc/latest/sitemap-1.xml", undefined, version); expect(await shard.text()).toBe(shardXml.replaceAll(`/doc/${version}/`, "/doc/latest/")); @@ -306,6 +274,7 @@ describe("documentation worker", () => { const response = await request("/doc/6.4.0/reference/PageChange.html", { headers: { Range: "bytes=20-30" } }); expect(response.status).toBe(416); expect(response.headers.get("content-range")).toBe("bytes */10"); + expect(response.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); }); it("resumes PDFs only when If-Range matches the current representation", async () => { @@ -375,7 +344,7 @@ describe("documentation worker", () => { const response = await worker.fetch(incoming, env, context); await waitOnExecutionContext(context); expect(originFetch).toHaveBeenLastCalledWith(incoming); - expect(response).toBe(upstream); + expect(response.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); expect(response.status).toBe(status); expect(response.headers.get("x-robots-tag")).toBe("index, follow"); expect(response.headers.get("link")).toBe('; rel="canonical"'); @@ -391,7 +360,10 @@ describe("documentation worker", () => { }); it("returns explicit errors", async () => { - expect((await request("/doc/6.4.0/missing.html")).status).toBe(404); + const missing = await request("/doc/6.4.0/missing.html"); + expect(missing.status).toBe(404); + expect(missing.headers.get("cache-control")).toBe("no-store"); + expect(missing.headers.get("cloudflare-cdn-cache-control")).toBe("public, max-age=300"); const method = await request("/doc/6.4.0/index.html", { method: "POST" }); expect(method.status).toBe(405); expect(method.headers.get("allow")).toBe("GET, HEAD"); @@ -399,7 +371,7 @@ describe("documentation worker", () => { expect((await request("/doc/6.4.0/%252e%252e/secret")).status).toBe(400); expect((await request("/doc/6.4.0/reference%2fPageChange.html")).status).toBe(400); }); - it("promotes revisions without reusing the previous selection's cache", async () => { + it("promotes and rolls back the selected revision", async () => { const relative = "modeling/revision-test/index.html"; for (const [revision, body] of [["r1", "first"], ["r2", "second"]]) { await env.DOCS.put(`_revisions/6.4.0/${revision}/${relative}`, body, { @@ -413,7 +385,7 @@ describe("documentation worker", () => { expect(await promoted.text()).toBe("second"); expect(promoted.headers.get("x-gecode-documentation-version")).toBe("6.4.0"); expect(promoted.headers.get("x-gecode-documentation-revision")).toBe("r2"); - expect(promoted.headers.get("cache-control")).toBe("public, max-age=300, s-maxage=300"); + expect(promoted.headers.get("cache-control")).toBe("public, max-age=86400"); const rollback = await request(prefix + relative, undefined, "6.4.0", '{"6.4.0":"r1"}'); expect(await rollback.text()).toBe("first"); } diff --git a/workers/docs/src/index.ts b/workers/docs/src/index.ts index 5b2de454c..b19cca4e8 100644 --- a/workers/docs/src/index.ts +++ b/workers/docs/src/index.ts @@ -38,6 +38,9 @@ const securityHeaders = { "X-Frame-Options": "SAMEORIGIN", }; +const documentationEdgeCache = "public, max-age=2592000, stale-while-revalidate=604800, stale-if-error=2592000"; +const sitemapEdgeCache = "public, max-age=86400, stale-while-revalidate=86400, stale-if-error=2592000"; + function errorResponse(status: number, message: string, extraHeaders: HeadersInit = {}): Response { return new Response( `${status} ${message}

${status}

${message}

`, @@ -45,6 +48,8 @@ function errorResponse(status: number, message: string, extraHeaders: HeadersIni status, headers: { "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Cloudflare-CDN-Cache-Control": status === 404 ? "public, max-age=300" : "no-store", ...securityHeaders, ...extraHeaders, }, @@ -127,6 +132,7 @@ function parseRange(value: string, size: number): { offset: number; length: numb } function applyObjectHeaders(headers: Headers, object: R2Object, resolved: ResolvedPath): void { + const isSitemap = /^sitemap(?:-\d+)?\.xml$/.test(resolved.relative); object.writeHttpMetadata(headers); headers.set("ETag", object.httpEtag); headers.set("Last-Modified", object.uploaded.toUTCString()); @@ -137,8 +143,11 @@ function applyObjectHeaders(headers: Headers, object: R2Object, resolved: Resolv "Cache-Control", resolved.isRevision ? "public, max-age=31536000, immutable" - : "public, max-age=300, s-maxage=300", + : isSitemap ? "public, max-age=300" : "public, max-age=86400", ); + headers.set("Cloudflare-CDN-Cache-Control", resolved.isRevision + ? "public, max-age=31536000, stale-while-revalidate=604800, stale-if-error=2592000" + : isSitemap ? sitemapEdgeCache : documentationEdgeCache); for (const [name, value] of Object.entries(securityHeaders)) headers.set(name, value); } @@ -152,8 +161,7 @@ function applyIndexingPolicy(request: Request, response: Response, env: Env): Re && !resolved.isRevision && [200, 206, 304].includes(response.status); const headers = new Headers(response.headers); - // Apply this after cache reads too: a previous deployment may have cached - // version-specific canonical links or different indexing instructions. + // Workers Cache stores the final response, including these indexing headers. headers.delete("Link"); if (indexable) { headers.delete("X-Robots-Tag"); @@ -211,7 +219,7 @@ async function sitemapResponse(request: Request, object: R2ObjectBody, resolved: return new Response(request.method === "HEAD" ? null : bytes, { status: 200, headers }); } -async function serve(request: Request, env: Env, context: ExecutionContext): Promise { +async function serve(request: Request, env: Env): Promise { if (request.method !== "GET" && request.method !== "HEAD") { return errorResponse(405, "Method not allowed", { Allow: "GET, HEAD" }); } @@ -222,14 +230,19 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro headers: { "Content-Type": "text/plain; charset=utf-8", "Content-Length": String(new TextEncoder().encode(robots).byteLength), - "Cache-Control": "public, max-age=300, s-maxage=300", + "Cache-Control": "public, max-age=300", + "Cloudflare-CDN-Cache-Control": sitemapEdgeCache, }, }); } const redirect = (pathname: string) => { const destination = new URL(url); destination.pathname = pathname; - return Response.redirect(destination.href, 308); + return new Response(null, { status: 308, headers: { + Location: destination.href, + "Cache-Control": "public, max-age=86400", + "Cloudflare-CDN-Cache-Control": documentationEdgeCache, + } }); }; if (url.pathname === "/doc" || url.pathname === "/doc/") return redirect("/documentation.html"); const resolved = resolvePath(url.pathname, env.LATEST_DOC_VERSION); @@ -251,35 +264,10 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro return errorResponse(404, "Documentation page not found"); }; - const cacheableRequest = request.method === "GET" - && !request.headers.has("Range") - && !request.headers.has("If-None-Match"); - const cacheUrl = new URL(request.url); - cacheUrl.search = ""; - // Promotion changes the physical key, including when the public version stays - // the same. The policy also avoids old immutable version-URL cache entries. - cacheUrl.searchParams.set("__gecode_docs_policy", "revisions-v1"); - cacheUrl.searchParams.set("__gecode_docs_object", resolved.key); - const cacheKey = new Request(cacheUrl, { method: "GET" }); - if (cacheableRequest) { - try { - const cached = await caches.default.match(cacheKey); - if (cached) return cached; - } catch (error) { - console.error(JSON.stringify({ event: "cache_read_failed", message: String(error) })); - } - } - if (resolved.isAlias && /^sitemap(?:-\d+)?\.xml$/.test(resolved.relative)) { const object = await env.DOCS.get(resolved.key); if (!object) return missingObject(); - const response = await sitemapResponse(request, object, resolved); - if (cacheableRequest) { - context.waitUntil(caches.default.put(cacheKey, response.clone()).catch((error) => { - console.error(JSON.stringify({ event: "cache_write_failed", message: String(error) })); - })); - } - return response; + return sitemapResponse(request, object, resolved); } // Range applies only to GET. HEAD describes the complete representation. @@ -301,6 +289,8 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro const range = rangeHeader && rangeMatches ? parseRange(rangeHeader, metadata.size) : null; if (range === "invalid") { headers.set("Content-Range", `bytes */${metadata.size}`); + headers.set("Cache-Control", "no-store"); + headers.set("Cloudflare-CDN-Cache-Control", "no-store"); return new Response(null, { status: 416, headers }); } if (range) { @@ -326,17 +316,11 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro return new Response(null, { status: 304, headers }); } headers.set("Content-Length", String(object.size)); - const response = new Response(object.body, { status: 200, headers }); - if (cacheableRequest) { - context.waitUntil(caches.default.put(cacheKey, response.clone()).catch((error) => { - console.error(JSON.stringify({ event: "cache_write_failed", message: String(error) })); - })); - } - return response; + return new Response(object.body, { status: 200, headers }); } export default { - async fetch(request: Request, env: Env, context: ExecutionContext): Promise { + async fetch(request: Request, env: Env, _context: ExecutionContext): Promise { const url = new URL(request.url); const pathname = url.pathname; const ownsPath = pathname === "/robots.txt" @@ -345,13 +329,19 @@ export default { // Wildcard routes also receive /documentation.html and similarly named // website paths. Leave their origin response and indexing headers intact. if (!ownsPath) { - if (url.hostname === "www.gecode.dev") return fetch(request); + if (url.hostname === "www.gecode.dev") { + const upstream = await fetch(request); + const response = new Response(upstream.body, upstream); + // Do not put neighboring website pages in the documentation cache. + response.headers.set("Cloudflare-CDN-Cache-Control", "no-store"); + return response; + } return applyIndexingPolicy(request, errorResponse(404, "Page not found"), env); } let response: Response; try { - response = await serve(request, env, context); + response = await serve(request, env); } catch (error) { console.error(JSON.stringify({ event: "documentation_storage_failure", diff --git a/workers/docs/wrangler.jsonc b/workers/docs/wrangler.jsonc index 58cefb010..b0fe4f912 100644 --- a/workers/docs/wrangler.jsonc +++ b/workers/docs/wrangler.jsonc @@ -4,6 +4,10 @@ "main": "src/index.ts", "compatibility_date": "2026-08-08", "workers_dev": true, + "cache": { + "enabled": true, + "cross_version_cache": false + }, "observability": { "enabled": true },