diff --git a/src/workerd/api/http.c++ b/src/workerd/api/http.c++ index 87d925d213c..9e18912a76c 100644 --- a/src/workerd/api/http.c++ +++ b/src/workerd/api/http.c++ @@ -689,17 +689,19 @@ kj::Maybe Request::serializeCfBlobJson(jsg::Lock& js) { if (!obj.has(js, "cacheControl")) { if (hasCacheMode) { // Synthesize from the cache request option. - switch (cacheMode) { - case CacheMode::NOSTORE: - obj.set(js, "cacheControl", js.str("no-store"_kjc)); - break; - case CacheMode::NOCACHE: - obj.set(js, "cacheControl", js.str("no-cache"_kjc)); - break; - case CacheMode::RELOAD: - break; - case CacheMode::NONE: - KJ_UNREACHABLE; + if (!FeatureFlags::get(js).getCacheOptionPreservesCacheControl()) { + switch (cacheMode) { + case CacheMode::NOSTORE: + obj.set(js, "cacheControl", js.str("no-store"_kjc)); + break; + case CacheMode::NOCACHE: + obj.set(js, "cacheControl", js.str("no-cache"_kjc)); + break; + case CacheMode::RELOAD: + break; + case CacheMode::NONE: + KJ_UNREACHABLE; + } } } else if (obj.has(js, "cacheTtl")) { // Synthesize from cacheTtl value: positive/zero → max-age=N, -1 → no-store. diff --git a/src/workerd/api/tests/cf-cache-control-test.js b/src/workerd/api/tests/cf-cache-control-test.js index d2df14441d8..df19f404997 100644 --- a/src/workerd/api/tests/cf-cache-control-test.js +++ b/src/workerd/api/tests/cf-cache-control-test.js @@ -6,6 +6,13 @@ import assert from 'node:assert'; +// Echoes the cf object this worker receives, so tests can inspect the cf blob a fetch serializes. +export default { + async fetch(request) { + return Response.json(request.cf ?? null); + }, +}; + // Tests for cf.cacheControl mutual exclusion and synthesis. // These tests run regardless of cache option flag since cacheControl is always available. export const cacheControlMutualExclusion = { @@ -203,3 +210,35 @@ export const additionalCacheSettings = { } }, }; + +export const cacheOptionSerialization = { + async test(ctrl, env, ctx) { + if (!env.CACHE_ENABLED) return; + const expectSynthesized = !env.CACHE_OPTION_PRESERVES_CACHE_CONTROL; + + const noCacheCf = await ( + await env.SERVICE.fetch('http://placeholder/', { cache: 'no-cache' }) + ).json(); + assert.strictEqual(noCacheCf.cacheForceRevalidate, true); + assert.strictEqual( + noCacheCf.cacheControl, + expectSynthesized ? 'no-cache' : undefined + ); + + const noStoreCf = await ( + await env.SERVICE.fetch('http://placeholder/', { cache: 'no-store' }) + ).json(); + assert.strictEqual(noStoreCf.cacheTtl, -1); + assert.strictEqual(noStoreCf.cacheLevel, 'bypass'); + assert.strictEqual( + noStoreCf.cacheControl, + expectSynthesized ? 'no-store' : undefined + ); + + // cacheTtl still synthesizes cacheControl regardless of the flag. + const ttlCf = await ( + await env.SERVICE.fetch('http://placeholder/', { cf: { cacheTtl: 300 } }) + ).json(); + assert.strictEqual(ttlCf.cacheControl, 'max-age=300'); + }, +}; diff --git a/src/workerd/api/tests/cf-cache-control-test.wd-test b/src/workerd/api/tests/cf-cache-control-test.wd-test index 00dd57384d8..86cbaba70ac 100644 --- a/src/workerd/api/tests/cf-cache-control-test.wd-test +++ b/src/workerd/api/tests/cf-cache-control-test.wd-test @@ -20,8 +20,26 @@ const unitTests :Workerd.Config = ( ], bindings = [ ( name = "CACHE_ENABLED", json = "true" ), + ( name = "SERVICE", service = "cf-cache-control-test-cache-enabled" ), + ( name = "CACHE_OPTION_PRESERVES_CACHE_CONTROL", json = "false" ), ], - compatibilityFlags = ["nodejs_compat", "cache_option_enabled"], + compatibilityFlags = ["nodejs_compat", "cache_option_enabled", "cache_no_cache_enabled", + "cache_option_overrides_cache_control"], + ) + ), + ( name = "cf-cache-control-test-cache-option-preserves-cache-control", + worker = ( + modules = [ + ( name = "worker-cache-option-preserves-cache-control", + esModule = embed "cf-cache-control-test.js" ) + ], + bindings = [ + ( name = "CACHE_ENABLED", json = "true" ), + ( name = "SERVICE", service = "cf-cache-control-test-cache-option-preserves-cache-control" ), + ( name = "CACHE_OPTION_PRESERVES_CACHE_CONTROL", json = "true" ), + ], + compatibilityFlags = ["nodejs_compat", "cache_option_enabled", "cache_no_cache_enabled", + "cache_option_preserves_cache_control"], ) ), ], diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 8a0307647e1..bbac0edd1bf 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1705,4 +1705,16 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # source fails, once that cancel completes. Previously, a destination that errored or closed # while the pipe waited on a read canceled the source but resolved the pipe promise, or left it # pending forever with preventCancel, and a failed cancel was not reported. + + cacheOptionPreservesCacheControl @194 :Bool + $compatEnableFlag("cache_option_preserves_cache_control") + $compatDisableFlag("cache_option_overrides_cache_control") + $compatEnableDate("2026-11-17"); + # Per the Fetch spec, a request's cache mode describes how the cache may use a stored response + # ("no-cache": revalidate it with the origin; "no-store": act as if there is no cache); it says + # nothing about how the response may be cached. Without this flag, `cache: 'no-cache'` and + # `cache: 'no-store'` also set `cf.cacheControl` to "no-cache" and "no-store", which overrides + # the origin's Cache-Control (for example, an origin `no-store` gets cached as `no-cache`). When + # enabled, the cache option no longer sets `cf.cacheControl`. To override the origin's + # Cache-Control, omit the `cache` option and use `cf.cacheControl` instead. }