@@ -673,7 +673,31 @@ 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+ // 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));
0 commit comments