Skip to content

Commit c682942

Browse files
committed
CACHE-13371: more cache knobs under request.cf object
1 parent 7143daf commit c682942

4 files changed

Lines changed: 322 additions & 21 deletions

File tree

src/workerd/api/http.c++

Lines changed: 109 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,40 @@ 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+
if (!cacheTtlVal.isUndefined()) {
802+
JSG_REQUIRE(cacheTtlVal.isUndefined, TypeError,
803+
"The 'cacheControl' and 'cacheTtl' options on cf are mutually exclusive. "
804+
"Use 'cacheControl' for explicit Cache-Control header directives, "
805+
"or 'cacheTtl' for a simplified TTL, but not both.");
806+
}
807+
}
808+
// cacheControl + cache option (no-store/no-cache) → throw
809+
// The cache request option maps to cacheTtl internally, so they conflict.
810+
if (cache != kj::none) {
811+
JSG_REQUIRE(TypeError,
812+
"The 'cacheControl' option on cf cannot be used together with the 'cache' "
813+
"request option. The 'cache' option ('no-store'/'no-cache') maps to cache TTL "
814+
"behavior internally, which conflicts with explicit Cache-Control directives.");
815+
}
816+
}
817+
}
818+
}
819+
}
820+
731821
KJ_IF_SOME(e, encodeResponseBody) {
732822
JSG_REQUIRE(e == "manual"_kj || e == "automatic"_kj, TypeError,
733823
kj::str("encodeResponseBody: unexpected value: ", e));

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

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

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const unitTests :Workerd.Config = (
1111
( name = "SERVICE", service = "http-test" ),
1212
( name = "CACHE_ENABLED", json = "false" ),
1313
],
14-
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_disabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "web_socket_auto_reply_to_close"],
14+
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_disabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "web_socket_auto_reply_to_close", "experimental"],
1515
)
1616
),
1717
( name = "http-test-cache-option-enabled",
@@ -23,7 +23,7 @@ const unitTests :Workerd.Config = (
2323
( name = "SERVICE", service = "http-test-cache-option-enabled" ),
2424
( name = "CACHE_ENABLED", json = "true" ),
2525
],
26-
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_enabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "web_socket_auto_reply_to_close"],
26+
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_enabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "web_socket_auto_reply_to_close", "experimental"],
2727
))
2828
],
2929
);

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)