Skip to content

Commit df9116c

Browse files
vibe-dexclaude
andauthored
feat(matcher): make matcher-wrapped pages cacheable at CDN edge (#1203)
Drop `Vary: cookie` from sticky-session matchers and stop treating framework-managed Set-Cookies (deco_matcher_*, deco_segment) as a reason to disable caching. Wire ctx.var.vary.shouldCache into the full-page kill-switch so loaders that declare cache:"no-store" (personalizing loaders) still veto caching. Adds a Deco-Cache-Vary-Cookies hint header so CDN operators can discover which cookies belong in the custom cache key. Extracts applyPageCacheDecision() from the inlined kill-switch as an exported pure function; the request middleware is the only production caller. Adds tests for matcher.ts and middleware.ts — the first tests in blocks/ and for runtime/middleware.ts. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9a64d10 commit df9116c

4 files changed

Lines changed: 357 additions & 23 deletions

File tree

blocks/matcher.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// deno-lint-ignore-file no-explicit-any
2+
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
3+
import { getSetCookies } from "../deps.ts";
4+
import matcherBlock, {
5+
DECO_MATCHER_PREFIX,
6+
type MatcherStickySessionModule,
7+
} from "./matcher.ts";
8+
9+
const buildHttpCtx = (respHeaders: Headers) =>
10+
({
11+
resolveChain: [{ type: "resolvable", value: "test-matcher-id" }],
12+
context: {
13+
state: {
14+
response: { headers: respHeaders },
15+
flags: [] as any[],
16+
global: {},
17+
bag: new WeakMap(),
18+
},
19+
},
20+
request: new Request("https://example.com/"),
21+
resolve: (() => {}) as any,
22+
revision: undefined,
23+
resolverId: "test-resolver",
24+
monitoring: undefined,
25+
}) as any;
26+
27+
const buildMatchCtx = (request: Request) =>
28+
({
29+
device: "desktop",
30+
siteId: 1,
31+
request,
32+
resolve: (() => {}) as any,
33+
invoke: (() => {}) as any,
34+
response: { headers: new Headers() },
35+
bag: new WeakMap(),
36+
}) as any;
37+
38+
Deno.test("sticky matcher flips result and sets cookie WITHOUT Vary: cookie", async () => {
39+
const respHeaders = new Headers();
40+
const httpCtx = buildHttpCtx(respHeaders);
41+
42+
const module: MatcherStickySessionModule = {
43+
default: () => true,
44+
sticky: "session",
45+
};
46+
47+
const result = await resolverFor(module, httpCtx, new Request("https://example.com/"));
48+
49+
assertEquals(result, true);
50+
51+
const setCookies = getSetCookies(respHeaders);
52+
assertEquals(setCookies.length, 1, "expected one Set-Cookie on respHeaders");
53+
assert(
54+
setCookies[0].name.startsWith(DECO_MATCHER_PREFIX),
55+
`expected Set-Cookie name to start with ${DECO_MATCHER_PREFIX}, got ${
56+
setCookies[0].name
57+
}`,
58+
);
59+
60+
const vary = respHeaders.get("vary") ?? "";
61+
assert(
62+
!vary.toLowerCase().includes("cookie"),
63+
`expected Vary header to NOT contain "cookie", got: ${vary}`,
64+
);
65+
});
66+
67+
Deno.test("sticky matcher with matching cookie does NOT set a cookie or Vary", async () => {
68+
const respHeaders = new Headers();
69+
const httpCtx = buildHttpCtx(respHeaders);
70+
71+
const module: MatcherStickySessionModule = {
72+
default: () => true,
73+
sticky: "session",
74+
};
75+
76+
// Build the cookie name the matcher would use, then set it on the request
77+
// with a value that decodes to `true` so result === isMatchFromCookie.
78+
const { Murmurhash3 } = await import("../deps.ts");
79+
const h = new Murmurhash3();
80+
h.hash("test-matcher-id");
81+
const cookieName = `${DECO_MATCHER_PREFIX}${h.result()}`;
82+
// cookieValue.build: btoa(id) + "@" + (result ? 1 : 0)
83+
const cookieVal = `${btoa("test-matcher-id")}@1`;
84+
85+
const request = new Request("https://example.com/", {
86+
headers: { cookie: `${cookieName}=${cookieVal}` },
87+
});
88+
89+
const result = await resolverFor(module, httpCtx, request);
90+
assertEquals(result, true);
91+
92+
assertEquals(
93+
getSetCookies(respHeaders).length,
94+
0,
95+
"expected no Set-Cookie when cookie value already matches result",
96+
);
97+
assertEquals(
98+
respHeaders.get("vary"),
99+
null,
100+
"expected no Vary header when nothing was emitted",
101+
);
102+
});
103+
104+
Deno.test("non-sticky matcher does not touch respHeaders", async () => {
105+
const respHeaders = new Headers();
106+
const httpCtx = buildHttpCtx(respHeaders);
107+
108+
const module = {
109+
default: () => true,
110+
sticky: "none" as const,
111+
};
112+
113+
const result = await resolverFor(
114+
module as any,
115+
httpCtx,
116+
new Request("https://example.com/"),
117+
);
118+
assertEquals(result, true);
119+
120+
assertEquals(getSetCookies(respHeaders).length, 0);
121+
assertEquals(respHeaders.get("vary"), null);
122+
});
123+
124+
// Regression guard: if anyone re-adds Vary: cookie inside the sticky branch,
125+
// this scan will fail. The string check is deliberately broad.
126+
Deno.test("matcher.ts source does not append Vary: cookie", async () => {
127+
const src = await Deno.readTextFile(new URL("./matcher.ts", import.meta.url));
128+
assert(
129+
!/append\(\s*["']vary["']\s*,\s*["']cookie["']\s*\)/i.test(src),
130+
"blocks/matcher.ts must not append Vary: cookie — that disables CDN caching",
131+
);
132+
// Sanity: ensure the cookie-setting code path is still there.
133+
assertStringIncludes(src, "setCookie(respHeaders");
134+
});
135+
136+
async function resolverFor(
137+
module: MatcherStickySessionModule | { default: any; sticky: "none" },
138+
httpCtx: any,
139+
request: Request,
140+
): Promise<boolean> {
141+
const adapt = matcherBlock.adapt as any;
142+
const resolver = adapt(module, "test-matcher-id")({}, httpCtx);
143+
return await resolver(buildMatchCtx(request));
144+
}

blocks/matcher.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,6 @@ const matcherBlock: Block<
215215
sameSite: "Lax",
216216
expires: date,
217217
});
218-
respHeaders.append("vary", "cookie");
219218
}
220219
}
221220

runtime/middleware.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { assert, assertEquals } from "@std/assert";
2+
import { setCookie } from "../utils/cookies.ts";
3+
import { DECO_MATCHER_PREFIX } from "../blocks/matcher.ts";
4+
import { applyPageCacheDecision, DECO_SEGMENT } from "./middleware.ts";
5+
6+
const matcherCookie = `${DECO_MATCHER_PREFIX}1234567890_0.5`;
7+
8+
const pageInput = {
9+
flags: [],
10+
isPageCacheAllowed: true,
11+
shouldCacheFromVary: true,
12+
};
13+
14+
Deno.test("no matcher, no Set-Cookie → public cache-control", () => {
15+
const headers = new Headers({ "Content-Type": "text/html" });
16+
applyPageCacheDecision(headers, pageInput);
17+
const cc = headers.get("Cache-Control") ?? "";
18+
assert(cc.startsWith("public,"), `expected public Cache-Control, got: ${cc}`);
19+
assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null);
20+
});
21+
22+
Deno.test("matcher Set-Cookie only → public cache-control + hint header", () => {
23+
const headers = new Headers({ "Content-Type": "text/html" });
24+
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });
25+
setCookie(headers, { name: DECO_SEGMENT, value: "%7B%7D", path: "/" });
26+
27+
applyPageCacheDecision(headers, pageInput);
28+
29+
const cc = headers.get("Cache-Control") ?? "";
30+
assert(cc.startsWith("public,"), `expected public Cache-Control, got: ${cc}`);
31+
32+
const hint = headers.get("Deco-Cache-Vary-Cookies") ?? "";
33+
assert(
34+
hint.includes(matcherCookie),
35+
`expected hint to include matcher cookie name, got: ${hint}`,
36+
);
37+
assert(
38+
hint.includes(DECO_SEGMENT),
39+
`expected hint to include deco_segment, got: ${hint}`,
40+
);
41+
});
42+
43+
Deno.test("foreign Set-Cookie → no-store (safety preserved)", () => {
44+
const headers = new Headers({ "Content-Type": "text/html" });
45+
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });
46+
setCookie(headers, { name: "cart_count", value: "3", path: "/" });
47+
48+
applyPageCacheDecision(headers, pageInput);
49+
50+
assertEquals(
51+
headers.get("Cache-Control"),
52+
"no-store, no-cache, must-revalidate",
53+
);
54+
assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null);
55+
});
56+
57+
Deno.test("vary.shouldCache=false (personalizing loader) → no-store", () => {
58+
const headers = new Headers({ "Content-Type": "text/html" });
59+
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });
60+
61+
applyPageCacheDecision(headers, {
62+
...pageInput,
63+
shouldCacheFromVary: false,
64+
});
65+
66+
assertEquals(
67+
headers.get("Cache-Control"),
68+
"no-store, no-cache, must-revalidate",
69+
);
70+
assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null);
71+
});
72+
73+
Deno.test("flag with cacheable:false → no-store", () => {
74+
const headers = new Headers({ "Content-Type": "text/html" });
75+
76+
applyPageCacheDecision(headers, {
77+
flags: [{ cacheable: false }],
78+
isPageCacheAllowed: true,
79+
shouldCacheFromVary: true,
80+
});
81+
82+
assertEquals(
83+
headers.get("Cache-Control"),
84+
"no-store, no-cache, must-revalidate",
85+
);
86+
});
87+
88+
Deno.test("isPageCacheAllowed=false → headers untouched", () => {
89+
const headers = new Headers({ "Content-Type": "text/html" });
90+
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });
91+
92+
applyPageCacheDecision(headers, {
93+
...pageInput,
94+
isPageCacheAllowed: false,
95+
});
96+
97+
assertEquals(headers.get("Cache-Control"), null);
98+
assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null);
99+
});
100+
101+
Deno.test("respects pre-existing Cache-Control header", () => {
102+
const headers = new Headers({
103+
"Content-Type": "text/html",
104+
"Cache-Control": "public, max-age=600",
105+
});
106+
107+
applyPageCacheDecision(headers, pageInput);
108+
109+
assertEquals(headers.get("Cache-Control"), "public, max-age=600");
110+
});
111+
112+
Deno.test(
113+
"cacheDisqualified overrides a pre-existing Cache-Control header",
114+
() => {
115+
const headers = new Headers({
116+
"Content-Type": "text/html",
117+
"Cache-Control": "public, max-age=600",
118+
});
119+
setCookie(headers, { name: "session_id", value: "xyz", path: "/" });
120+
121+
applyPageCacheDecision(headers, pageInput);
122+
123+
assertEquals(
124+
headers.get("Cache-Control"),
125+
"no-store, no-cache, must-revalidate",
126+
);
127+
},
128+
);

0 commit comments

Comments
 (0)