From aea6aa72772fc77ce331e341ec2f2faa392b67ae Mon Sep 17 00:00:00 2001 From: guitavano Date: Thu, 24 Sep 2026 16:05:43 -0300 Subject: [PATCH] feat(runtime): conditional GET (ETag/304) for cacheable HTML pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cacheable pages carry `Cache-Control: max-age, stale-while-revalidate` but no validator, so every CDN revalidation (once per max-age, per cache variant, per POP) re-fetches the FULL body from the origin even when the render is the content the CDN already holds. On a high-traffic catalog with a short max-age this revalidation churn is the dominant origin egress. This emits a weak ETag on cacheable 200 HTML and answers 304 when the CDN revalidates with a matching If-None-Match — collapsing an unchanged revalidation to headers only. The ETag hashes the rendered HTML with only per-request COSMETIC tokens masked out (CSP `nonce`, `renderSalt`). Everything semantic — prices, stock, product order, copy — stays in the hash, so any real change yields a fresh 200. The ETag never governs freshness (max-age/SWR + loader cache still do); it only avoids re-sending a body the CDN already has, and a token that slips the mask just varies the ETag (no 304, no harm). Gated to cacheable 200 HTML (a no-store response is never revalidated). The body is buffered to hash it; streaming is preserved for every other response, and cacheable renders only happen on MISS/revalidation (the CDN serves users), so the buffering is off the hot path. Co-Authored-By: Claude Opus 4.8 --- runtime/conditionalGet.test.ts | 57 ++++++++++++++++++++++++++++++ runtime/conditionalGet.ts | 64 ++++++++++++++++++++++++++++++++++ runtime/middleware.ts | 30 +++++++++++++--- 3 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 runtime/conditionalGet.test.ts create mode 100644 runtime/conditionalGet.ts diff --git a/runtime/conditionalGet.test.ts b/runtime/conditionalGet.test.ts new file mode 100644 index 000000000..cbbe62ab6 --- /dev/null +++ b/runtime/conditionalGet.test.ts @@ -0,0 +1,57 @@ +import { assert, assertEquals, assertNotEquals } from "@std/assert"; +import { + ifNoneMatchSatisfied, + normalizeForEtag, + weakEtagFor, +} from "./conditionalGet.ts"; + +// Only the tokens the engine masks (CSP nonce, renderSalt query param) vary +// here; everything else is semantic content that must land in the hash. +const page = (opts: { nonce: string; salt: string; price: number }) => + `` + + `` + + `` + + `x` + + `R$ ${opts.price}` + + ``; + +Deno.test("normalizeForEtag masks nonce and renderSalt", () => { + const out = normalizeForEtag( + '?renderSalt=deadbeef-01&__cb=1', + ); + assertEquals( + out, + '?renderSalt=&__cb=1', + ); +}); + +Deno.test("ETag is stable across differing nonce and renderSalt", async () => { + const a = await weakEtagFor(page({ nonce: "n1", salt: "s1", price: 449 })); + const b = await weakEtagFor(page({ nonce: "n2", salt: "s2", price: 449 })); + assertEquals(a, b); + assert(a.startsWith('W/"')); +}); + +Deno.test("ETag changes when the price changes (never masked)", async () => { + const a = await weakEtagFor(page({ nonce: "n1", salt: "s1", price: 449 })); + const b = await weakEtagFor(page({ nonce: "n1", salt: "s1", price: 359 })); + assertNotEquals(a, b); +}); + +Deno.test("If-None-Match weak comparison matches our weak ETag", async () => { + const etag = await weakEtagFor(page({ nonce: "n", salt: "s", price: 449 })); + assert(ifNoneMatchSatisfied(etag, etag)); + // strong form of the same opaque value still matches (weak comparison) + assert(ifNoneMatchSatisfied(etag.replace(/^W\//, ""), etag)); + // present in a list + assert(ifNoneMatchSatisfied(`W/"other", ${etag}`, etag)); + // wildcard + assert(ifNoneMatchSatisfied("*", etag)); +}); + +Deno.test("If-None-Match does not match a different ETag", async () => { + const etag = await weakEtagFor(page({ nonce: "n", salt: "s", price: 449 })); + const other = await weakEtagFor(page({ nonce: "n", salt: "s", price: 99 })); + assert(!ifNoneMatchSatisfied(other, etag)); + assert(!ifNoneMatchSatisfied('W/"nope"', etag)); +}); diff --git a/runtime/conditionalGet.ts b/runtime/conditionalGet.ts new file mode 100644 index 000000000..774cfc582 --- /dev/null +++ b/runtime/conditionalGet.ts @@ -0,0 +1,64 @@ +/** + * Conditional GET (ETag / `If-None-Match` → `304 Not Modified`) for cacheable + * HTML page responses. + * + * The engine emits `Cache-Control: ...max-age=..., stale-while-revalidate=...` + * on cacheable pages but never a validator. So every CDN revalidation — once + * per `max-age`, per cache variant, per POP — re-fetches the FULL body from the + * origin, even when the render is byte-for-byte the content the CDN already + * holds. Emitting a stable ETag and answering `304` collapses those + * revalidation transfers to headers only, which is the dominant origin egress + * cost on a high-traffic catalog with a short `max-age`. + * + * The ETag hashes the rendered HTML with only the per-request COSMETIC tokens + * masked out: + * - the CSP `nonce` (unique per response by design, security-critical), and + * - the `renderSalt` used to disambiguate section instance ids. + * EVERYTHING semantic — prices, stock, product order, copy — stays in the hash, + * so any real content change yields a new ETag and a fresh `200`. The ETag + * never governs freshness (`max-age`/SWR + the loader cache still do); it only + * avoids re-sending a body the CDN already has. A masked token that slips + * through simply varies the ETag → no 304 → no harm, just no saving. + */ + +const NONCE = /nonce="[^"]*"/g; +// renderSalt appears both as an attribute-ish token and inside `/deco/render` +// URLs; stop at the delimiters that bound a query-param value or attribute. +const RENDER_SALT = /renderSalt=[^"'&\s\\]*/g; + +/** Strip per-request cosmetic tokens so equal content hashes equal. */ +export const normalizeForEtag = (html: string): string => + html.replace(NONCE, 'nonce=""').replace(RENDER_SALT, "renderSalt="); + +const toHex = (buffer: ArrayBuffer): string => + Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + +/** + * Weak ETag (`W/"..."`) for a rendered HTML body. Weak because it is computed + * over a normalized body, so it asserts semantic — not byte — equivalence. + */ +export const weakEtagFor = async (html: string): Promise => { + const bytes = new TextEncoder().encode(normalizeForEtag(html)); + const digest = await crypto.subtle.digest("SHA-1", bytes); + return `W/"${toHex(digest)}"`; +}; + +const stripWeakPrefix = (tag: string): string => tag.trim().replace(/^W\//, ""); + +/** + * Weak comparison of a request's `If-None-Match` against our ETag, per + * RFC 9110 §13.1.2 (weak comparison is the one allowed for `If-None-Match`). + */ +export const ifNoneMatchSatisfied = ( + ifNoneMatch: string, + etag: string, +): boolean => { + const value = ifNoneMatch.trim(); + if (value === "*") return true; + const target = stripWeakPrefix(etag); + return value.split(",").some((candidate) => + stripWeakPrefix(candidate) === target + ); +}; diff --git a/runtime/middleware.ts b/runtime/middleware.ts index 85c01065d..ab7e100c1 100644 --- a/runtime/middleware.ts +++ b/runtime/middleware.ts @@ -5,6 +5,7 @@ import { DECO_MATCHER_PREFIX, } from "../blocks/matcher.ts"; import { PAGE_CACHE_ALLOWED_KEY } from "../blocks/utils.tsx"; +import { ifNoneMatchSatisfied, weakEtagFor } from "./conditionalGet.ts"; import { Context, context } from "../deco.ts"; import { type Exception, @@ -543,11 +544,21 @@ export const middlewareFor = ( ? buildDraftBadge(draftPointer) : null; + // Conditional GET: cacheable HTML gets an ETag so CDN revalidations + // (once per max-age, per variant, per POP) can answer 304 with no body + // instead of re-streaming an unchanged render. Gated to cacheable 200 HTML + // — a no-store response is never revalidated, so an ETag on it is moot. + const conditionalGetEnabled = isPageCacheAllowed && + responseStatus === 200 && isHtmlResponse && + !(newHeaders.get("Cache-Control") ?? "").includes("no-store"); + // for some reason hono deletes content-type when response is not fresh. // which means that sometimes it will fail as headers are immutable. // so I'm first setting it to undefined and just then set the entire response again ctx.res = undefined; - if (cookieScript || draftBadge) { + // Buffer the body when we inject into it OR when we need to hash it for a + // conditional GET; streaming is preserved for every other response. + if (cookieScript || draftBadge || conditionalGetEnabled) { let html = await initialResponse.text(); if (cookieScript) { // Script captured the framework cookies; remove the now-redundant @@ -560,10 +571,19 @@ export const middlewareFor = ( html = injectScriptIntoHtml(html, cookieScript); } if (draftBadge) html = injectBeforeBodyEnd(html, draftBadge); - ctx.res = new Response(html, { - status: responseStatus, - headers: newHeaders, - }); + + let notModified = false; + if (conditionalGetEnabled) { + const etag = await weakEtagFor(html); + newHeaders.set("ETag", etag); + const ifNoneMatch = ctx.req.raw.headers.get("if-none-match"); + notModified = ifNoneMatch != null && + ifNoneMatchSatisfied(ifNoneMatch, etag); + } + + ctx.res = notModified + ? new Response(null, { status: 304, headers: newHeaders }) + : new Response(html, { status: responseStatus, headers: newHeaders }); } else { ctx.res = new Response(initialResponse.body, { status: responseStatus,