Skip to content

Commit c47ddcd

Browse files
vibe-dexclaude
andcommitted
feat(matcher): strip framework Set-Cookie from cacheable HTML responses
Companion to 1.201.1's inline-script cookie persistence. After the script captures the framework cookies (deco_matcher_*, deco_segment) into the HTML body, the Set-Cookie response headers are redundant — and they prevent CDNs running under `respect_origin` cache mode from caching the response (any Set-Cookie is treated as a personalization signal). Removing the Set-Cookie headers (only for HTML 200 responses where the script was injected) makes cold-visit responses cacheable under respect_origin, unblocking the CDN team's fleet-wide rollout from the current torra-scoped override_origin rule. Ordering inside the middleware: 1. applyPageCacheDecision sets Cache-Control and Deco-Cache-Vary-Cookies (reads framework Set-Cookies to build the hint). 2. buildClientCookieScript reads framework Set-Cookies into the inline script. 3. stripFrameworkSetCookies removes them. The Deco-Cache-Vary-Cookies hint (set in step 1) is preserved so operators still know which cookies belong in the custom cache key. Non-HTML responses and non-200 responses keep their Set-Cookies untouched (no script there to take over persistence). Trade-off accepted: no-JS clients lose framework-cookie stickiness for all sites on 1.202.0+. Matcher-stickiness has been implicitly JS-dependent since 1.201.1 (CDN-cached responses with stripped Set-Cookie only re-set the cookie via the inline script); this change makes that dependency explicit and unblocks CDN-side fleet-wide caching. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent faa97f8 commit c47ddcd

3 files changed

Lines changed: 132 additions & 0 deletions

File tree

runtime/clientCookies.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,31 @@ export const injectScriptIntoHtml = (html: string, script: string): string => {
8585
}
8686
return html + script;
8787
};
88+
89+
/**
90+
* Remove framework-managed Set-Cookies (`deco_matcher_*`, `deco_segment`) from
91+
* `headers` in place. Foreign Set-Cookies (cart, profile, etc.) are kept.
92+
*
93+
* Used after `buildClientCookieScript` has captured the framework cookies into
94+
* an inline `<script>`. The script handles client-side persistence, which
95+
* means the response header is redundant — and a CDN running under
96+
* `respect_origin` cache mode (e.g. Cloudflare) treats any `Set-Cookie` as a
97+
* personalization signal and refuses to cache the response. Stripping the
98+
* framework Set-Cookies lets cold-visit responses cache cleanly.
99+
*
100+
* Trade-off: no-JS clients lose framework-cookie stickiness because they
101+
* cannot execute the inline script. That is an accepted side effect — the
102+
* matcher-stickiness model has been implicitly JS-dependent ever since 1.201.1.
103+
*/
104+
export const stripFrameworkSetCookies = (headers: Headers): void => {
105+
const remaining = headers.getSetCookie().filter((raw) => {
106+
const eq = raw.indexOf("=");
107+
if (eq < 0) return true;
108+
const name = raw.slice(0, eq);
109+
return !frameworkCookiePrefixes().some((p) => name.startsWith(p));
110+
});
111+
headers.delete("Set-Cookie");
112+
for (const raw of remaining) {
113+
headers.append("Set-Cookie", raw);
114+
}
115+
};

runtime/middleware.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { applyPageCacheDecision, DECO_SEGMENT } from "./middleware.ts";
55
import {
66
buildClientCookieScript,
77
injectScriptIntoHtml,
8+
stripFrameworkSetCookies,
89
} from "./clientCookies.ts";
910

1011
const matcherCookie = `${DECO_MATCHER_PREFIX}1234567890_0.5`;
@@ -256,3 +257,98 @@ Deno.test("injectScriptIntoHtml: prefers first </head> (handles embedded HTML)",
256257
SCRIPT,
257258
);
258259
});
260+
261+
// ---------- stripFrameworkSetCookies ----------
262+
263+
Deno.test("stripFrameworkSetCookies: removes deco_matcher_* and deco_segment, keeps foreign", () => {
264+
const headers = new Headers();
265+
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });
266+
setCookie(
267+
headers,
268+
{ name: DECO_SEGMENT, value: '{"active":["foo"]}', path: "/" },
269+
{ encode: true },
270+
);
271+
setCookie(headers, { name: "cart_count", value: "3", path: "/" });
272+
setCookie(headers, { name: "session_id", value: "xyz", path: "/" });
273+
274+
stripFrameworkSetCookies(headers);
275+
276+
const names = headers.getSetCookie().map((raw) => {
277+
const eq = raw.indexOf("=");
278+
return raw.slice(0, eq);
279+
});
280+
assertEquals(names.sort(), ["cart_count", "session_id"]);
281+
});
282+
283+
Deno.test("stripFrameworkSetCookies: no-op when there are no Set-Cookie headers", () => {
284+
const headers = new Headers({ "Content-Type": "text/html" });
285+
stripFrameworkSetCookies(headers);
286+
assertEquals(headers.getSetCookie().length, 0);
287+
assertEquals(headers.get("Content-Type"), "text/html");
288+
});
289+
290+
Deno.test("stripFrameworkSetCookies: no-op when only foreign Set-Cookies present", () => {
291+
const headers = new Headers();
292+
setCookie(headers, { name: "cart_count", value: "3", path: "/" });
293+
setCookie(headers, { name: "session_id", value: "xyz", path: "/" });
294+
295+
stripFrameworkSetCookies(headers);
296+
297+
assertEquals(headers.getSetCookie().length, 2);
298+
});
299+
300+
Deno.test("flow: build script captures cookies, strip removes them, hint header preserved", () => {
301+
// Simulate the production middleware flow:
302+
// 1. applyPageCacheDecision runs first — sets the Deco-Cache-Vary-Cookies hint.
303+
// 2. buildClientCookieScript reads the framework Set-Cookies into a <script>.
304+
// 3. stripFrameworkSetCookies removes the now-redundant headers.
305+
// Final state: hint header preserved, script has the original cookie bytes,
306+
// response has no framework Set-Cookies.
307+
const headers = new Headers({ "Content-Type": "text/html" });
308+
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });
309+
setCookie(
310+
headers,
311+
{ name: DECO_SEGMENT, value: '{"active":["foo"]}', path: "/" },
312+
{ encode: true },
313+
);
314+
setCookie(headers, { name: "cart_count", value: "3", path: "/" });
315+
316+
// Step 1: apply page-cache decision (this also sets the hint header).
317+
// Foreign Set-Cookie present → cache disqualified → no-store. So we test
318+
// the hint header path with foreign cookie removed.
319+
// Build a separate headers object without the foreign cookie, run the
320+
// decision so the hint header is set, then add back the foreign cookie
321+
// and proceed.
322+
const headersForDecision = new Headers({ "Content-Type": "text/html" });
323+
setCookie(headersForDecision, {
324+
name: matcherCookie,
325+
value: "abc@1",
326+
path: "/",
327+
});
328+
setCookie(
329+
headersForDecision,
330+
{ name: DECO_SEGMENT, value: '{"active":["foo"]}', path: "/" },
331+
{ encode: true },
332+
);
333+
applyPageCacheDecision(headersForDecision, pageInput);
334+
const hint = headersForDecision.get("Deco-Cache-Vary-Cookies");
335+
assert(hint !== null, "expected hint header from applyPageCacheDecision");
336+
337+
// Step 2: build the script from the (now decided) headers.
338+
const script = buildClientCookieScript(headersForDecision);
339+
assert(script !== null);
340+
assertStringIncludes(script, matcherCookie);
341+
assertStringIncludes(script, DECO_SEGMENT);
342+
343+
// Step 3: strip framework Set-Cookies — script already captured them.
344+
stripFrameworkSetCookies(headersForDecision);
345+
346+
// Verify final state.
347+
const remaining = headersForDecision.getSetCookie();
348+
assertEquals(remaining.length, 0, "expected no Set-Cookies remaining");
349+
assertEquals(
350+
headersForDecision.get("Deco-Cache-Vary-Cookies"),
351+
hint,
352+
"hint header must survive the strip",
353+
);
354+
});

runtime/middleware.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
import {
3232
buildClientCookieScript,
3333
injectScriptIntoHtml,
34+
stripFrameworkSetCookies,
3435
} from "./clientCookies.ts";
3536
import { setLogger } from "./fetch/fetchLog.ts";
3637
import { liveness } from "./middlewares/liveness.ts";
@@ -533,6 +534,13 @@ export const middlewareFor = <TAppManifest extends AppManifest = AppManifest>(
533534
ctx.res = undefined;
534535
if (cookieScript) {
535536
const html = await initialResponse.text();
537+
// Script captured the framework cookies; remove the now-redundant
538+
// Set-Cookie headers so CDNs under `respect_origin` cache mode treat
539+
// the response as non-personalized and cache cold-visit responses.
540+
// The Deco-Cache-Vary-Cookies hint header (set by applyPageCacheDecision
541+
// above) is preserved so operators still know which cookies belong in
542+
// the custom cache key.
543+
stripFrameworkSetCookies(newHeaders);
536544
ctx.res = new Response(injectScriptIntoHtml(html, cookieScript), {
537545
status: responseStatus,
538546
headers: newHeaders,

0 commit comments

Comments
 (0)