@@ -673,7 +673,30 @@ void Request::shallowCopyHeadersTo(kj::HttpHeaders& out) {
673673}
674674
675675kj::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));
0 commit comments