@@ -673,7 +673,33 @@ 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+ if (!ttlVal.isUndefined ()) {
696+ needsSynthesizedCacheControl = true ;
697+ }
698+ }
699+ }
700+ }
701+
702+ if (!hasCacheMode && !needsSynthesizedCacheControl) {
677703 return cf.serialize (js);
678704 }
679705
@@ -687,25 +713,58 @@ kj::Maybe<kj::String> Request::serializeCfBlobJson(jsg::Lock& js) {
687713 auto obj = KJ_ASSERT_NONNULL (clone.get (js));
688714
689715 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." ));
716+ if (hasCacheMode) {
717+ switch (cacheMode) {
718+ case CacheMode::NOSTORE:
719+ if (obj.has (js, " cacheTtl" )) {
720+ jsg::JsValue oldTtl = obj.get (js, " cacheTtl" );
721+ JSG_REQUIRE (oldTtl.strictEquals (js.num (NOCACHE_TTL)), TypeError,
722+ kj::str (" CacheTtl: " , oldTtl, " , is not compatible with cache: " ,
723+ getCacheModeName (cacheMode).orDefault (" none" _kj), " header." ));
724+ } else {
725+ obj.set (js, " cacheTtl" , js.num (NOCACHE_TTL));
726+ }
727+ KJ_FALLTHROUGH;
728+ case CacheMode::RELOAD:
729+ obj.set (js, " cacheLevel" , js.str (" bypass" _kjc));
730+ break ;
731+ case CacheMode::NOCACHE:
732+ obj.set (js, " cacheForceRevalidate" , js.boolean (true ));
733+ break ;
734+ case CacheMode::NONE:
735+ KJ_UNREACHABLE;
736+ }
737+ }
738+
739+ // Synthesize cacheControl from cacheTtl or cacheMode when cacheControl is not explicitly set.
740+ // This dual-writes both fields so downstream can migrate to cacheControl incrementally.
741+ // TODO(cleanup): Remove the workerdExperimental gate once validated in production.
742+ if (experimentalCacheControl && !obj.has (js, " cacheControl" )) {
743+ if (hasCacheMode) {
744+ // Synthesize from the cache request option.
745+ switch (cacheMode) {
746+ case CacheMode::NOSTORE:
747+ obj.set (js, " cacheControl" , js.str (" no-store" _kjc));
748+ break ;
749+ case CacheMode::NOCACHE:
750+ obj.set (js, " cacheControl" , js.str (" no-cache" _kjc));
751+ break ;
752+ case CacheMode::RELOAD:
753+ break ;
754+ case CacheMode::NONE:
755+ KJ_UNREACHABLE;
756+ }
757+ } else if (obj.has (js, " cacheTtl" )) {
758+ // Synthesize from cacheTtl value: positive/zero → max-age=N, -1 → no-store.
759+ jsg::JsValue ttlVal = obj.get (js, " cacheTtl" );
760+ if (ttlVal.strictEquals (js.num (NOCACHE_TTL))) {
761+ obj.set (js, " cacheControl" , js.str (" no-store" _kjc));
697762 } else {
698- obj.set (js, " cacheTtl" , js.num (NOCACHE_TTL));
763+ v8::Local<v8::Value> ttlHandle = ttlVal;
764+ auto ttl = jsg::check (ttlHandle->IntegerValue (js.v8Context ()));
765+ obj.set (js, " cacheControl" , js.str (kj::str (" max-age=" , ttl)));
699766 }
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;
767+ }
709768 }
710769
711770 return clone.serialize (js);
@@ -728,6 +787,40 @@ void RequestInitializerDict::validate(jsg::Lock& js) {
728787 !invalidNoCache && !invalidReload, TypeError, kj::str (" Unsupported cache mode: " , c));
729788 }
730789
790+ // Validate mutual exclusion of cf.cacheControl with cf.cacheTtl and the cache request option.
791+ // cacheControl provides explicit Cache-Control header override and cannot be combined with
792+ // cacheTtl (which sets a simplified TTL) or the cache option (which maps to cacheTtl internally).
793+ // cacheTtlByStatus is allowed alongside cacheControl since they serve different purposes.
794+ // TODO(cleanup): Remove the workerdExperimental gate once validated in production.
795+ if (FeatureFlags::get (js).getWorkerdExperimental ()) {
796+ KJ_IF_SOME (cfRef, cf) {
797+ auto cfObj = jsg::JsObject (cfRef.getHandle (js));
798+ if (cfObj.has (js, " cacheControl" )) {
799+ auto cacheControlVal = cfObj.get (js, " cacheControl" );
800+ if (!cacheControlVal.isUndefined ()) {
801+ // cacheControl + cacheTtl → throw
802+ if (cfObj.has (js, " cacheTtl" )) {
803+ auto cacheTtlVal = cfObj.get (js, " cacheTtl" );
804+ if (!cacheTtlVal.isUndefined ()) {
805+ JSG_FAIL_REQUIRE (TypeError,
806+ " The 'cacheControl' and 'cacheTtl' options on cf are mutually exclusive. "
807+ " Use 'cacheControl' for explicit Cache-Control header directives, "
808+ " or 'cacheTtl' for a simplified TTL, but not both." );
809+ }
810+ }
811+ // cacheControl + cache option (no-store/no-cache) → throw
812+ // The cache request option maps to cacheTtl internally, so they conflict.
813+ if (cache != kj::none) {
814+ JSG_FAIL_REQUIRE (TypeError,
815+ " The 'cacheControl' option on cf cannot be used together with the 'cache' "
816+ " request option. The 'cache' option ('no-store'/'no-cache') maps to cache TTL "
817+ " behavior internally, which conflicts with explicit Cache-Control directives." );
818+ }
819+ }
820+ }
821+ }
822+ }
823+
731824 KJ_IF_SOME (e, encodeResponseBody) {
732825 JSG_REQUIRE (e == " manual" _kj || e == " automatic" _kj, TypeError,
733826 kj::str (" encodeResponseBody: unexpected value: " , e));
0 commit comments