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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions runtime/conditionalGet.test.ts
Original file line number Diff line number Diff line change
@@ -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 }) =>
`<!DOCTYPE html><html><head>` +
`<script nonce="${opts.nonce}">a=1</script>` +
`</head><body>` +
`<a href="/deco/render?props=%7B%7D&renderSalt=${opts.salt}&__cb=abc">x</a>` +
`<span data-price="${opts.price}">R$ ${opts.price}</span>` +
`</body></html>`;

Deno.test("normalizeForEtag masks nonce and renderSalt", () => {
const out = normalizeForEtag(
'<script nonce="abc123">z</script>?renderSalt=deadbeef-01&__cb=1',
);
assertEquals(
out,
'<script nonce="">z</script>?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));
});
64 changes: 64 additions & 0 deletions runtime/conditionalGet.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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
);
};
30 changes: 25 additions & 5 deletions runtime/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -543,11 +544,21 @@ export const middlewareFor = <TAppManifest extends AppManifest = AppManifest>(
? 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
Expand All @@ -560,10 +571,19 @@ export const middlewareFor = <TAppManifest extends AppManifest = AppManifest>(
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,
Expand Down
Loading