Skip to content

Commit cea6afc

Browse files
committed
CACHE-13371: more cache knobs under request.cf object
1 parent a629fd2 commit cea6afc

File tree

3 files changed

+308
-18
lines changed

3 files changed

+308
-18
lines changed

src/workerd/api/http.c++

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,30 @@ 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+
if (!hasCacheMode) {
688+
// Check if cf has cacheTtl but no cacheControl — we'll need to synthesize cacheControl.
689+
KJ_IF_SOME(cfObj, cf.get(js)) {
690+
if (!cfObj.has(js, "cacheControl") && cfObj.has(js, "cacheTtl")) {
691+
auto ttlVal = cfObj.get(js, "cacheTtl");
692+
if (!ttlVal.isUndefined()) {
693+
needsSynthesizedCacheControl = true;
694+
}
695+
}
696+
}
697+
}
698+
699+
if (!hasCacheMode && !needsSynthesizedCacheControl) {
677700
return cf.serialize(js);
678701
}
679702

@@ -687,25 +710,55 @@ kj::Maybe<kj::String> Request::serializeCfBlobJson(jsg::Lock& js) {
687710
auto obj = KJ_ASSERT_NONNULL(clone.get(js));
688711

689712
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."));
713+
if (hasCacheMode) {
714+
switch (cacheMode) {
715+
case CacheMode::NOSTORE:
716+
if (obj.has(js, "cacheTtl")) {
717+
jsg::JsValue oldTtl = obj.get(js, "cacheTtl");
718+
JSG_REQUIRE(oldTtl.strictEquals(js.num(NOCACHE_TTL)), TypeError,
719+
kj::str("CacheTtl: ", oldTtl, ", is not compatible with cache: ",
720+
getCacheModeName(cacheMode).orDefault("none"_kj), " header."));
721+
} else {
722+
obj.set(js, "cacheTtl", js.num(NOCACHE_TTL));
723+
}
724+
KJ_FALLTHROUGH;
725+
case CacheMode::RELOAD:
726+
obj.set(js, "cacheLevel", js.str("bypass"_kjc));
727+
break;
728+
case CacheMode::NOCACHE:
729+
obj.set(js, "cacheForceRevalidate", js.boolean(true));
730+
break;
731+
case CacheMode::NONE:
732+
KJ_UNREACHABLE;
733+
}
734+
}
735+
736+
// Synthesize cacheControl from cacheTtl or cacheMode when cacheControl is not explicitly set.
737+
// This dual-writes both fields so downstream can migrate to cacheControl incrementally.
738+
if (!obj.has(js, "cacheControl")) {
739+
if (hasCacheMode) {
740+
// Synthesize from the cache request option.
741+
switch (cacheMode) {
742+
case CacheMode::NOSTORE:
743+
obj.set(js, "cacheControl", js.str("no-store"_kjc));
744+
break;
745+
case CacheMode::NOCACHE:
746+
case CacheMode::RELOAD:
747+
obj.set(js, "cacheControl", js.str("no-cache"_kjc));
748+
break;
749+
case CacheMode::NONE:
750+
KJ_UNREACHABLE;
751+
}
752+
} else if (obj.has(js, "cacheTtl")) {
753+
// Synthesize from cacheTtl value: positive/zero → max-age=N, -1 → no-store.
754+
jsg::JsValue ttlVal = obj.get(js, "cacheTtl");
755+
auto ttl = static_cast<int>(ttlVal.toNumber(js));
756+
if (ttl == NOCACHE_TTL) {
757+
obj.set(js, "cacheControl", js.str("no-store"_kjc));
697758
} else {
698-
obj.set(js, "cacheTtl", js.num(NOCACHE_TTL));
759+
obj.set(js, "cacheControl", js.str(kj::str("max-age=", ttl)));
699760
}
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;
761+
}
709762
}
710763

711764
return clone.serialize(js);
@@ -728,6 +781,37 @@ void RequestInitializerDict::validate(jsg::Lock& js) {
728781
!invalidNoCache && !invalidReload, TypeError, kj::str("Unsupported cache mode: ", c));
729782
}
730783

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

src/workerd/api/tests/http-test.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,174 @@ export const cacheMode = {
419419
}
420420
},
421421
};
422+
423+
// Tests for cf.cacheControl mutual exclusion and synthesis.
424+
// These tests run regardless of cache option flag since cacheControl is always available.
425+
export const cacheControlMutualExclusion = {
426+
async test(ctrl, env, ctx) {
427+
// cacheControl + cacheTtl → TypeError at construction time
428+
assert.throws(
429+
() => new Request('https://example.org', {
430+
cf: { cacheControl: 'max-age=300', cacheTtl: 300 },
431+
}),
432+
{
433+
name: 'TypeError',
434+
message: /cacheControl.*cacheTtl.*mutually exclusive/,
435+
}
436+
);
437+
438+
// cacheControl alone should succeed
439+
{
440+
const req = new Request('https://example.org', {
441+
cf: { cacheControl: 'public, max-age=3600' },
442+
});
443+
assert.ok(req.cf);
444+
}
445+
446+
// cacheTtl alone should succeed
447+
{
448+
const req = new Request('https://example.org', {
449+
cf: { cacheTtl: 300 },
450+
});
451+
assert.ok(req.cf);
452+
}
453+
454+
// cacheControl + cacheTtlByStatus should succeed (not mutually exclusive)
455+
{
456+
const req = new Request('https://example.org', {
457+
cf: { cacheControl: 'public, max-age=3600', cacheTtlByStatus: { '200-299': 86400 } },
458+
});
459+
assert.ok(req.cf);
460+
}
461+
462+
// cacheControl with undefined cacheTtl should succeed (only non-undefined triggers conflict)
463+
{
464+
const req = new Request('https://example.org', {
465+
cf: { cacheControl: 'max-age=300', cacheTtl: undefined },
466+
});
467+
assert.ok(req.cf);
468+
}
469+
},
470+
};
471+
472+
export const cacheControlWithCacheOption = {
473+
async test(ctrl, env, ctx) {
474+
if (!env.CACHE_ENABLED) return;
475+
476+
// cache option + cf.cacheControl → TypeError at construction time
477+
assert.throws(
478+
() => new Request('https://example.org', {
479+
cache: 'no-store',
480+
cf: { cacheControl: 'no-cache' },
481+
}),
482+
{
483+
name: 'TypeError',
484+
message: /cacheControl.*cannot be used together with the.*cache/,
485+
}
486+
);
487+
488+
// cache: 'no-cache' + cf.cacheControl → also TypeError
489+
// (need cache_no_cache flag for this, skip if not available)
490+
},
491+
};
492+
493+
export const cacheControlSynthesis = {
494+
async test(ctrl, env, ctx) {
495+
// When cacheTtl is set without cacheControl, cacheControl should be synthesized
496+
// in the serialized cf blob. We verify by checking the cf property roundtrips correctly.
497+
498+
// cacheTtl: 300 → cacheControl should be synthesized as "max-age=300"
499+
{
500+
const req = new Request('https://example.org', {
501+
cf: { cacheTtl: 300 },
502+
});
503+
// The cf object at construction time won't have cacheControl yet —
504+
// synthesis happens at serialization (fetch) time in serializeCfBlobJson.
505+
// We can verify the request constructs fine.
506+
assert.ok(req.cf);
507+
assert.strictEqual(req.cf.cacheTtl, 300);
508+
}
509+
510+
// cacheTtl: -1 → cacheControl should be synthesized as "no-store"
511+
{
512+
const req = new Request('https://example.org', {
513+
cf: { cacheTtl: -1 },
514+
});
515+
assert.ok(req.cf);
516+
assert.strictEqual(req.cf.cacheTtl, -1);
517+
}
518+
519+
// cacheTtl: 0 → cacheControl should be synthesized as "max-age=0"
520+
{
521+
const req = new Request('https://example.org', {
522+
cf: { cacheTtl: 0 },
523+
});
524+
assert.ok(req.cf);
525+
assert.strictEqual(req.cf.cacheTtl, 0);
526+
}
527+
528+
// Explicit cacheControl should NOT be overwritten
529+
{
530+
const req = new Request('https://example.org', {
531+
cf: { cacheControl: 'public, s-maxage=86400' },
532+
});
533+
assert.ok(req.cf);
534+
assert.strictEqual(req.cf.cacheControl, 'public, s-maxage=86400');
535+
}
536+
},
537+
};
538+
539+
export const additionalCacheSettings = {
540+
async test(ctrl, env, ctx) {
541+
// All additional cache settings should be accepted on the cf object
542+
{
543+
const req = new Request('https://example.org', {
544+
cf: {
545+
cacheReserveEligible: true,
546+
respectStrongEtag: true,
547+
stripEtags: false,
548+
stripLastModified: false,
549+
cacheDeceptionArmor: true,
550+
cacheReserveMinimumFileSize: 1024,
551+
},
552+
});
553+
assert.ok(req.cf);
554+
assert.strictEqual(req.cf.cacheReserveEligible, true);
555+
assert.strictEqual(req.cf.respectStrongEtag, true);
556+
assert.strictEqual(req.cf.stripEtags, false);
557+
assert.strictEqual(req.cf.stripLastModified, false);
558+
assert.strictEqual(req.cf.cacheDeceptionArmor, true);
559+
assert.strictEqual(req.cf.cacheReserveMinimumFileSize, 1024);
560+
}
561+
562+
// Additional cache settings should work alongside cacheControl
563+
{
564+
const req = new Request('https://example.org', {
565+
cf: {
566+
cacheControl: 'public, max-age=3600',
567+
cacheReserveEligible: true,
568+
stripEtags: true,
569+
},
570+
});
571+
assert.ok(req.cf);
572+
assert.strictEqual(req.cf.cacheControl, 'public, max-age=3600');
573+
assert.strictEqual(req.cf.cacheReserveEligible, true);
574+
assert.strictEqual(req.cf.stripEtags, true);
575+
}
576+
577+
// Additional cache settings should work alongside cacheTtl
578+
{
579+
const req = new Request('https://example.org', {
580+
cf: {
581+
cacheTtl: 300,
582+
respectStrongEtag: true,
583+
cacheDeceptionArmor: true,
584+
},
585+
});
586+
assert.ok(req.cf);
587+
assert.strictEqual(req.cf.cacheTtl, 300);
588+
assert.strictEqual(req.cf.respectStrongEtag, true);
589+
assert.strictEqual(req.cf.cacheDeceptionArmor, true);
590+
}
591+
},
592+
};

types/defines/cf.d.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,41 @@ interface RequestInitCfProperties extends Record<string, unknown> {
119119
* (e.g. { '200-299': 86400, '404': 1, '500-599': 0 })
120120
*/
121121
cacheTtlByStatus?: Record<string, number>;
122+
/**
123+
* Explicit Cache-Control header value to set on the response stored in cache.
124+
* This gives full control over cache directives (e.g. 'public, max-age=3600, s-maxage=86400').
125+
*
126+
* Cannot be used together with `cacheTtl` or the `cache` request option (`no-store`/`no-cache`),
127+
* as these are mutually exclusive cache control mechanisms. Setting both will throw a TypeError.
128+
*
129+
* Can be used together with `cacheTtlByStatus`.
130+
*/
131+
cacheControl?: string;
132+
/**
133+
* Whether the response should be eligible for Cache Reserve storage.
134+
*/
135+
cacheReserveEligible?: boolean;
136+
/**
137+
* Whether to respect strong ETags (as opposed to weak ETags) from the origin.
138+
*/
139+
respectStrongEtag?: boolean;
140+
/**
141+
* Whether to strip ETag headers from the origin response before caching.
142+
*/
143+
stripEtags?: boolean;
144+
/**
145+
* Whether to strip Last-Modified headers from the origin response before caching.
146+
*/
147+
stripLastModified?: boolean;
148+
/**
149+
* Whether to enable Cache Deception Armor, which protects against web cache
150+
* deception attacks by verifying the Content-Type matches the URL extension.
151+
*/
152+
cacheDeceptionArmor?: boolean;
153+
/**
154+
* Minimum file size in bytes for a response to be eligible for Cache Reserve storage.
155+
*/
156+
cacheReserveMinimumFileSize?: number;
122157
scrapeShield?: boolean;
123158
apps?: boolean;
124159
image?: RequestInitCfPropertiesImage;

0 commit comments

Comments
 (0)