Skip to content

Commit 5279734

Browse files
authored
Merge pull request #6365 from cloudflare/yawar/CACHE-13371_add-cf-cache-control-field
CACHE-13371: more cache knobs under request.cf object
2 parents 1f638e0 + dd099be commit 5279734

9 files changed

Lines changed: 498 additions & 19 deletions

File tree

src/workerd/api/http.c++

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,31 @@ void Request::shallowCopyHeadersTo(kj::HttpHeaders& out) {
673673
}
674674

675675
kj::Maybe<kj::String> Request::serializeCfBlobJson(jsg::Lock& js) {
676-
if (cacheMode == CacheMode::NONE) {
676+
// We need to clone the cf object if we're going to modify it. We modify it when:
677+
// 1. cacheMode != NONE (existing behavior: map cache option to cacheTtl/cacheLevel/etc.)
678+
// 2. cacheControl is not explicitly set and we need to synthesize it from cacheTtl or cacheMode
679+
//
680+
// For backward compatibility during migration, we dual-write: keep cacheTtl as-is but also
681+
// synthesize cacheControl so downstream services can start consuming the unified field.
682+
// Once downstream fully migrates to cacheControl, cacheTtl can be removed.
683+
684+
bool hasCacheMode = (cacheMode != CacheMode::NONE);
685+
bool needsSynthesizedCacheControl = false;
686+
687+
// TODO(cleanup): Remove the workerdExperimental gate once validated in production.
688+
bool experimentalCacheControl = FeatureFlags::get(js).getWorkerdExperimental();
689+
690+
if (!hasCacheMode && experimentalCacheControl) {
691+
// Check if cf has cacheTtl but no cacheControl — we'll need to synthesize cacheControl.
692+
KJ_IF_SOME(cfObj, cf.get(js)) {
693+
if (!cfObj.has(js, "cacheControl") && cfObj.has(js, "cacheTtl")) {
694+
auto ttlVal = cfObj.get(js, "cacheTtl");
695+
needsSynthesizedCacheControl = !ttlVal.isUndefined();
696+
}
697+
}
698+
}
699+
700+
if (!hasCacheMode && !needsSynthesizedCacheControl) {
677701
return cf.serialize(js);
678702
}
679703

@@ -687,25 +711,57 @@ kj::Maybe<kj::String> Request::serializeCfBlobJson(jsg::Lock& js) {
687711
auto obj = KJ_ASSERT_NONNULL(clone.get(js));
688712

689713
constexpr int NOCACHE_TTL = -1;
690-
switch (cacheMode) {
691-
case CacheMode::NOSTORE:
692-
if (obj.has(js, "cacheTtl")) {
693-
jsg::JsValue oldTtl = obj.get(js, "cacheTtl");
694-
JSG_REQUIRE(oldTtl.strictEquals(js.num(NOCACHE_TTL)), TypeError,
695-
kj::str("CacheTtl: ", oldTtl, ", is not compatible with cache: ",
696-
getCacheModeName(cacheMode).orDefault("none"_kj), " header."));
697-
} else {
698-
obj.set(js, "cacheTtl", js.num(NOCACHE_TTL));
714+
if (hasCacheMode) {
715+
switch (cacheMode) {
716+
case CacheMode::NOSTORE:
717+
if (obj.has(js, "cacheTtl")) {
718+
jsg::JsValue oldTtl = obj.get(js, "cacheTtl");
719+
JSG_REQUIRE(oldTtl.strictEquals(js.num(NOCACHE_TTL)), TypeError,
720+
kj::str("CacheTtl: ", oldTtl, ", is not compatible with cache: ",
721+
getCacheModeName(cacheMode).orDefault("none"_kj), " header."));
722+
} else {
723+
obj.set(js, "cacheTtl", js.num(NOCACHE_TTL));
724+
}
725+
KJ_FALLTHROUGH;
726+
case CacheMode::RELOAD:
727+
obj.set(js, "cacheLevel", js.str("bypass"_kjc));
728+
break;
729+
case CacheMode::NOCACHE:
730+
obj.set(js, "cacheForceRevalidate", js.boolean(true));
731+
break;
732+
case CacheMode::NONE:
733+
KJ_UNREACHABLE;
734+
}
735+
}
736+
737+
// Synthesize cacheControl from cacheTtl or cacheMode when cacheControl is not explicitly set.
738+
// This dual-writes both fields so downstream can migrate to cacheControl incrementally.
739+
// TODO(cleanup): Remove the workerdExperimental gate once validated in production.
740+
if (experimentalCacheControl && !obj.has(js, "cacheControl")) {
741+
if (hasCacheMode) {
742+
// Synthesize from the cache request option.
743+
switch (cacheMode) {
744+
case CacheMode::NOSTORE:
745+
obj.set(js, "cacheControl", js.str("no-store"_kjc));
746+
break;
747+
case CacheMode::NOCACHE:
748+
obj.set(js, "cacheControl", js.str("no-cache"_kjc));
749+
break;
750+
case CacheMode::RELOAD:
751+
break;
752+
case CacheMode::NONE:
753+
KJ_UNREACHABLE;
699754
}
700-
KJ_FALLTHROUGH;
701-
case CacheMode::RELOAD:
702-
obj.set(js, "cacheLevel", js.str("bypass"_kjc));
703-
break;
704-
case CacheMode::NOCACHE:
705-
obj.set(js, "cacheForceRevalidate", js.boolean(true));
706-
break;
707-
case CacheMode::NONE:
708-
KJ_UNREACHABLE;
755+
} else if (obj.has(js, "cacheTtl")) {
756+
// Synthesize from cacheTtl value: positive/zero → max-age=N, -1 → no-store.
757+
jsg::JsValue ttlVal = obj.get(js, "cacheTtl");
758+
if (ttlVal.strictEquals(js.num(NOCACHE_TTL))) {
759+
obj.set(js, "cacheControl", js.str("no-store"_kjc));
760+
} else KJ_IF_SOME(ttlInt, ttlVal.tryCast<jsg::JsInt32>()) {
761+
auto ttl = KJ_ASSERT_NONNULL(ttlInt.value(js));
762+
obj.set(js, "cacheControl", js.str(kj::str("max-age=", ttl)));
763+
}
764+
}
709765
}
710766

711767
return clone.serialize(js);
@@ -728,6 +784,36 @@ void RequestInitializerDict::validate(jsg::Lock& js) {
728784
!invalidNoCache && !invalidReload, TypeError, kj::str("Unsupported cache mode: ", c));
729785
}
730786

787+
// Validate mutual exclusion of cf.cacheControl with cf.cacheTtl and the cache request option.
788+
// cacheControl provides explicit Cache-Control header override and cannot be combined with
789+
// cacheTtl (which sets a simplified TTL) or the cache option (which maps to cacheTtl internally).
790+
// cacheTtlByStatus is allowed alongside cacheControl since they serve different purposes.
791+
// TODO(cleanup): Remove the workerdExperimental gate once validated in production.
792+
if (FeatureFlags::get(js).getWorkerdExperimental()) {
793+
KJ_IF_SOME(cfRef, cf) {
794+
auto cfObj = jsg::JsObject(cfRef.getHandle(js));
795+
if (cfObj.has(js, "cacheControl")) {
796+
auto cacheControlVal = cfObj.get(js, "cacheControl");
797+
if (!cacheControlVal.isUndefined()) {
798+
// cacheControl + cacheTtl → throw
799+
if (cfObj.has(js, "cacheTtl")) {
800+
auto cacheTtlVal = cfObj.get(js, "cacheTtl");
801+
JSG_REQUIRE(cacheTtlVal.isUndefined(), TypeError,
802+
"The 'cacheControl' and 'cacheTtl' options on cf are mutually exclusive. "
803+
"Use 'cacheControl' for explicit Cache-Control header directives, "
804+
"or 'cacheTtl' for a simplified TTL, but not both.");
805+
}
806+
// cacheControl + cache option (no-store/no-cache) → throw
807+
// The cache request option maps to cacheTtl internally, so they conflict.
808+
JSG_REQUIRE(cache == kj::none, TypeError,
809+
"The 'cacheControl' option on cf cannot be used together with the 'cache' "
810+
"request option. The 'cache' option ('no-store'/'no-cache') maps to cache TTL "
811+
"behavior internally, which conflicts with explicit Cache-Control directives.");
812+
}
813+
}
814+
}
815+
}
816+
731817
KJ_IF_SOME(e, encodeResponseBody) {
732818
JSG_REQUIRE(e == "manual"_kj || e == "automatic"_kj, TypeError,
733819
kj::str("encodeResponseBody: unexpected value: ", e));

src/workerd/api/tests/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ wd_test(
8484
data = ["http-test.js"],
8585
)
8686

87+
wd_test(
88+
src = "cf-cache-control-test.wd-test",
89+
args = ["--experimental"],
90+
data = ["cf-cache-control-test.js"],
91+
)
92+
8793
wd_test(
8894
src = "ctx-props-test.wd-test",
8995
args = ["--experimental"],
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) 2025 Cloudflare, Inc.
2+
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3+
// https://opensource.org/licenses/Apache-2.0
4+
5+
// Tests for cf.cacheControl mutual exclusion, synthesis, and additional cache settings.
6+
// These require the workerd_experimental compat flag.
7+
8+
import assert from 'node:assert';
9+
10+
// Tests for cf.cacheControl mutual exclusion and synthesis.
11+
// These tests run regardless of cache option flag since cacheControl is always available.
12+
export const cacheControlMutualExclusion = {
13+
async test(ctrl, env, ctx) {
14+
// cacheControl + cacheTtl → TypeError at construction time
15+
assert.throws(
16+
() =>
17+
new Request('https://example.org', {
18+
cf: { cacheControl: 'max-age=300', cacheTtl: 300 },
19+
}),
20+
{
21+
name: 'TypeError',
22+
message: /cacheControl.*cacheTtl.*mutually exclusive/,
23+
}
24+
);
25+
26+
// cacheControl alone should succeed
27+
{
28+
const req = new Request('https://example.org', {
29+
cf: { cacheControl: 'public, max-age=3600' },
30+
});
31+
assert.ok(req.cf);
32+
}
33+
34+
// cacheTtl alone should succeed
35+
{
36+
const req = new Request('https://example.org', {
37+
cf: { cacheTtl: 300 },
38+
});
39+
assert.ok(req.cf);
40+
}
41+
42+
// cacheControl + cacheTtlByStatus should succeed (not mutually exclusive)
43+
{
44+
const req = new Request('https://example.org', {
45+
cf: {
46+
cacheControl: 'public, max-age=3600',
47+
cacheTtlByStatus: { '200-299': 86400 },
48+
},
49+
});
50+
assert.ok(req.cf);
51+
}
52+
53+
// cacheControl with undefined cacheTtl should succeed (only non-undefined triggers conflict)
54+
{
55+
const req = new Request('https://example.org', {
56+
cf: { cacheControl: 'max-age=300', cacheTtl: undefined },
57+
});
58+
assert.ok(req.cf);
59+
}
60+
},
61+
};
62+
63+
export const cacheControlWithCacheOption = {
64+
async test(ctrl, env, ctx) {
65+
if (!env.CACHE_ENABLED) return;
66+
67+
// cache option + cf.cacheControl → TypeError at construction time
68+
assert.throws(
69+
() =>
70+
new Request('https://example.org', {
71+
cache: 'no-store',
72+
cf: { cacheControl: 'no-cache' },
73+
}),
74+
{
75+
name: 'TypeError',
76+
message: /cacheControl.*cannot be used together with the.*cache/,
77+
}
78+
);
79+
80+
// cache: 'no-cache' + cf.cacheControl → also TypeError
81+
// (need cache_no_cache flag for this, skip if not available)
82+
},
83+
};
84+
85+
export const cacheControlSynthesis = {
86+
async test(ctrl, env, ctx) {
87+
// When cacheTtl is set without cacheControl, cacheControl should be synthesized
88+
// in the serialized cf blob. We verify by checking the cf property roundtrips correctly.
89+
90+
// cacheTtl: 300 → cacheControl should be synthesized as "max-age=300"
91+
{
92+
const req = new Request('https://example.org', {
93+
cf: { cacheTtl: 300 },
94+
});
95+
// The cf object at construction time won't have cacheControl yet —
96+
// synthesis happens at serialization (fetch) time in serializeCfBlobJson.
97+
// We can verify the request constructs fine.
98+
assert.ok(req.cf);
99+
assert.strictEqual(req.cf.cacheTtl, 300);
100+
}
101+
102+
// cacheTtl: -1 → cacheControl should be synthesized as "no-store"
103+
{
104+
const req = new Request('https://example.org', {
105+
cf: { cacheTtl: -1 },
106+
});
107+
assert.ok(req.cf);
108+
assert.strictEqual(req.cf.cacheTtl, -1);
109+
}
110+
111+
// cacheTtl: 0 → cacheControl should be synthesized as "max-age=0"
112+
{
113+
const req = new Request('https://example.org', {
114+
cf: { cacheTtl: 0 },
115+
});
116+
assert.ok(req.cf);
117+
assert.strictEqual(req.cf.cacheTtl, 0);
118+
}
119+
120+
// Explicit cacheControl should NOT be overwritten
121+
{
122+
const req = new Request('https://example.org', {
123+
cf: { cacheControl: 'public, s-maxage=86400' },
124+
});
125+
assert.ok(req.cf);
126+
assert.strictEqual(req.cf.cacheControl, 'public, s-maxage=86400');
127+
}
128+
},
129+
};
130+
131+
export const additionalCacheSettings = {
132+
async test(ctrl, env, ctx) {
133+
// All additional cache settings should be accepted on the cf object
134+
{
135+
const req = new Request('https://example.org', {
136+
cf: {
137+
cacheReserveEligible: true,
138+
respectStrongEtag: true,
139+
stripEtags: false,
140+
stripLastModified: false,
141+
cacheDeceptionArmor: true,
142+
cacheReserveMinimumFileSize: 1024,
143+
},
144+
});
145+
assert.ok(req.cf);
146+
assert.strictEqual(req.cf.cacheReserveEligible, true);
147+
assert.strictEqual(req.cf.respectStrongEtag, true);
148+
assert.strictEqual(req.cf.stripEtags, false);
149+
assert.strictEqual(req.cf.stripLastModified, false);
150+
assert.strictEqual(req.cf.cacheDeceptionArmor, true);
151+
assert.strictEqual(req.cf.cacheReserveMinimumFileSize, 1024);
152+
}
153+
154+
// Additional cache settings should work alongside cacheControl
155+
{
156+
const req = new Request('https://example.org', {
157+
cf: {
158+
cacheControl: 'public, max-age=3600',
159+
cacheReserveEligible: true,
160+
stripEtags: true,
161+
},
162+
});
163+
assert.ok(req.cf);
164+
assert.strictEqual(req.cf.cacheControl, 'public, max-age=3600');
165+
assert.strictEqual(req.cf.cacheReserveEligible, true);
166+
assert.strictEqual(req.cf.stripEtags, true);
167+
}
168+
169+
// Additional cache settings should work alongside cacheTtl
170+
{
171+
const req = new Request('https://example.org', {
172+
cf: {
173+
cacheTtl: 300,
174+
respectStrongEtag: true,
175+
cacheDeceptionArmor: true,
176+
},
177+
});
178+
assert.ok(req.cf);
179+
assert.strictEqual(req.cf.cacheTtl, 300);
180+
assert.strictEqual(req.cf.respectStrongEtag, true);
181+
assert.strictEqual(req.cf.cacheDeceptionArmor, true);
182+
}
183+
},
184+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Workerd = import "/workerd/workerd.capnp";
2+
3+
const unitTests :Workerd.Config = (
4+
services = [
5+
( name = "cf-cache-control-test",
6+
worker = (
7+
modules = [
8+
( name = "worker", esModule = embed "cf-cache-control-test.js" )
9+
],
10+
bindings = [
11+
( name = "CACHE_ENABLED", json = "false" ),
12+
],
13+
compatibilityFlags = ["nodejs_compat", "cache_option_disabled", "experimental"],
14+
)
15+
),
16+
( name = "cf-cache-control-test-cache-enabled",
17+
worker = (
18+
modules = [
19+
( name = "worker-cache-enabled", esModule = embed "cf-cache-control-test.js" )
20+
],
21+
bindings = [
22+
( name = "CACHE_ENABLED", json = "true" ),
23+
],
24+
compatibilityFlags = ["nodejs_compat", "cache_option_enabled", "experimental"],
25+
)
26+
),
27+
],
28+
);

0 commit comments

Comments
 (0)