diff --git a/src/workerd/api/http.c++ b/src/workerd/api/http.c++ index ff4dc18b223..8c513e0334e 100644 --- a/src/workerd/api/http.c++ +++ b/src/workerd/api/http.c++ @@ -673,7 +673,31 @@ void Request::shallowCopyHeadersTo(kj::HttpHeaders& out) { } kj::Maybe Request::serializeCfBlobJson(jsg::Lock& js) { - if (cacheMode == CacheMode::NONE) { + // We need to clone the cf object if we're going to modify it. We modify it when: + // 1. cacheMode != NONE (existing behavior: map cache option to cacheTtl/cacheLevel/etc.) + // 2. cacheControl is not explicitly set and we need to synthesize it from cacheTtl or cacheMode + // + // For backward compatibility during migration, we dual-write: keep cacheTtl as-is but also + // synthesize cacheControl so downstream services can start consuming the unified field. + // Once downstream fully migrates to cacheControl, cacheTtl can be removed. + + bool hasCacheMode = (cacheMode != CacheMode::NONE); + bool needsSynthesizedCacheControl = false; + + // TODO(cleanup): Remove the workerdExperimental gate once validated in production. + bool experimentalCacheControl = FeatureFlags::get(js).getWorkerdExperimental(); + + if (!hasCacheMode && experimentalCacheControl) { + // Check if cf has cacheTtl but no cacheControl — we'll need to synthesize cacheControl. + KJ_IF_SOME(cfObj, cf.get(js)) { + if (!cfObj.has(js, "cacheControl") && cfObj.has(js, "cacheTtl")) { + auto ttlVal = cfObj.get(js, "cacheTtl"); + needsSynthesizedCacheControl = !ttlVal.isUndefined(); + } + } + } + + if (!hasCacheMode && !needsSynthesizedCacheControl) { return cf.serialize(js); } @@ -687,25 +711,57 @@ kj::Maybe Request::serializeCfBlobJson(jsg::Lock& js) { auto obj = KJ_ASSERT_NONNULL(clone.get(js)); constexpr int NOCACHE_TTL = -1; - switch (cacheMode) { - case CacheMode::NOSTORE: - if (obj.has(js, "cacheTtl")) { - jsg::JsValue oldTtl = obj.get(js, "cacheTtl"); - JSG_REQUIRE(oldTtl.strictEquals(js.num(NOCACHE_TTL)), TypeError, - kj::str("CacheTtl: ", oldTtl, ", is not compatible with cache: ", - getCacheModeName(cacheMode).orDefault("none"_kj), " header.")); - } else { - obj.set(js, "cacheTtl", js.num(NOCACHE_TTL)); + if (hasCacheMode) { + switch (cacheMode) { + case CacheMode::NOSTORE: + if (obj.has(js, "cacheTtl")) { + jsg::JsValue oldTtl = obj.get(js, "cacheTtl"); + JSG_REQUIRE(oldTtl.strictEquals(js.num(NOCACHE_TTL)), TypeError, + kj::str("CacheTtl: ", oldTtl, ", is not compatible with cache: ", + getCacheModeName(cacheMode).orDefault("none"_kj), " header.")); + } else { + obj.set(js, "cacheTtl", js.num(NOCACHE_TTL)); + } + KJ_FALLTHROUGH; + case CacheMode::RELOAD: + obj.set(js, "cacheLevel", js.str("bypass"_kjc)); + break; + case CacheMode::NOCACHE: + obj.set(js, "cacheForceRevalidate", js.boolean(true)); + break; + case CacheMode::NONE: + KJ_UNREACHABLE; + } + } + + // Synthesize cacheControl from cacheTtl or cacheMode when cacheControl is not explicitly set. + // This dual-writes both fields so downstream can migrate to cacheControl incrementally. + // TODO(cleanup): Remove the workerdExperimental gate once validated in production. + if (experimentalCacheControl && !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; } - KJ_FALLTHROUGH; - case CacheMode::RELOAD: - obj.set(js, "cacheLevel", js.str("bypass"_kjc)); - break; - case CacheMode::NOCACHE: - obj.set(js, "cacheForceRevalidate", js.boolean(true)); - break; - case CacheMode::NONE: - KJ_UNREACHABLE; + } else if (obj.has(js, "cacheTtl")) { + // Synthesize from cacheTtl value: positive/zero → max-age=N, -1 → no-store. + jsg::JsValue ttlVal = obj.get(js, "cacheTtl"); + if (ttlVal.strictEquals(js.num(NOCACHE_TTL))) { + obj.set(js, "cacheControl", js.str("no-store"_kjc)); + } else KJ_IF_SOME(ttlInt, ttlVal.tryCast()) { + auto ttl = KJ_ASSERT_NONNULL(ttlInt.value(js)); + obj.set(js, "cacheControl", js.str(kj::str("max-age=", ttl))); + } + } } return clone.serialize(js); @@ -728,6 +784,36 @@ void RequestInitializerDict::validate(jsg::Lock& js) { !invalidNoCache && !invalidReload, TypeError, kj::str("Unsupported cache mode: ", c)); } + // Validate mutual exclusion of cf.cacheControl with cf.cacheTtl and the cache request option. + // cacheControl provides explicit Cache-Control header override and cannot be combined with + // cacheTtl (which sets a simplified TTL) or the cache option (which maps to cacheTtl internally). + // cacheTtlByStatus is allowed alongside cacheControl since they serve different purposes. + // TODO(cleanup): Remove the workerdExperimental gate once validated in production. + if (FeatureFlags::get(js).getWorkerdExperimental()) { + KJ_IF_SOME(cfRef, cf) { + auto cfObj = jsg::JsObject(cfRef.getHandle(js)); + if (cfObj.has(js, "cacheControl")) { + auto cacheControlVal = cfObj.get(js, "cacheControl"); + if (!cacheControlVal.isUndefined()) { + // cacheControl + cacheTtl → throw + if (cfObj.has(js, "cacheTtl")) { + auto cacheTtlVal = cfObj.get(js, "cacheTtl"); + JSG_REQUIRE(cacheTtlVal.isUndefined(), TypeError, + "The 'cacheControl' and 'cacheTtl' options on cf are mutually exclusive. " + "Use 'cacheControl' for explicit Cache-Control header directives, " + "or 'cacheTtl' for a simplified TTL, but not both."); + } + // cacheControl + cache option (no-store/no-cache) → throw + // The cache request option maps to cacheTtl internally, so they conflict. + JSG_REQUIRE(cache == kj::none, TypeError, + "The 'cacheControl' option on cf cannot be used together with the 'cache' " + "request option. The 'cache' option ('no-store'/'no-cache') maps to cache TTL " + "behavior internally, which conflicts with explicit Cache-Control directives."); + } + } + } + } + KJ_IF_SOME(e, encodeResponseBody) { JSG_REQUIRE(e == "manual"_kj || e == "automatic"_kj, TypeError, kj::str("encodeResponseBody: unexpected value: ", e)); diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 55dad299471..a6c153b48a3 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -84,6 +84,12 @@ wd_test( data = ["http-test.js"], ) +wd_test( + src = "cf-cache-control-test.wd-test", + args = ["--experimental"], + data = ["cf-cache-control-test.js"], +) + wd_test( src = "ctx-props-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/tests/cf-cache-control-test.js b/src/workerd/api/tests/cf-cache-control-test.js new file mode 100644 index 00000000000..7d68c3c9081 --- /dev/null +++ b/src/workerd/api/tests/cf-cache-control-test.js @@ -0,0 +1,184 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Tests for cf.cacheControl mutual exclusion, synthesis, and additional cache settings. +// These require the workerd_experimental compat flag. + +import assert from 'node:assert'; + +// Tests for cf.cacheControl mutual exclusion and synthesis. +// These tests run regardless of cache option flag since cacheControl is always available. +export const cacheControlMutualExclusion = { + async test(ctrl, env, ctx) { + // cacheControl + cacheTtl → TypeError at construction time + assert.throws( + () => + new Request('https://example.org', { + cf: { cacheControl: 'max-age=300', cacheTtl: 300 }, + }), + { + name: 'TypeError', + message: /cacheControl.*cacheTtl.*mutually exclusive/, + } + ); + + // cacheControl alone should succeed + { + const req = new Request('https://example.org', { + cf: { cacheControl: 'public, max-age=3600' }, + }); + assert.ok(req.cf); + } + + // cacheTtl alone should succeed + { + const req = new Request('https://example.org', { + cf: { cacheTtl: 300 }, + }); + assert.ok(req.cf); + } + + // cacheControl + cacheTtlByStatus should succeed (not mutually exclusive) + { + const req = new Request('https://example.org', { + cf: { + cacheControl: 'public, max-age=3600', + cacheTtlByStatus: { '200-299': 86400 }, + }, + }); + assert.ok(req.cf); + } + + // cacheControl with undefined cacheTtl should succeed (only non-undefined triggers conflict) + { + const req = new Request('https://example.org', { + cf: { cacheControl: 'max-age=300', cacheTtl: undefined }, + }); + assert.ok(req.cf); + } + }, +}; + +export const cacheControlWithCacheOption = { + async test(ctrl, env, ctx) { + if (!env.CACHE_ENABLED) return; + + // cache option + cf.cacheControl → TypeError at construction time + assert.throws( + () => + new Request('https://example.org', { + cache: 'no-store', + cf: { cacheControl: 'no-cache' }, + }), + { + name: 'TypeError', + message: /cacheControl.*cannot be used together with the.*cache/, + } + ); + + // cache: 'no-cache' + cf.cacheControl → also TypeError + // (need cache_no_cache flag for this, skip if not available) + }, +}; + +export const cacheControlSynthesis = { + async test(ctrl, env, ctx) { + // When cacheTtl is set without cacheControl, cacheControl should be synthesized + // in the serialized cf blob. We verify by checking the cf property roundtrips correctly. + + // cacheTtl: 300 → cacheControl should be synthesized as "max-age=300" + { + const req = new Request('https://example.org', { + cf: { cacheTtl: 300 }, + }); + // The cf object at construction time won't have cacheControl yet — + // synthesis happens at serialization (fetch) time in serializeCfBlobJson. + // We can verify the request constructs fine. + assert.ok(req.cf); + assert.strictEqual(req.cf.cacheTtl, 300); + } + + // cacheTtl: -1 → cacheControl should be synthesized as "no-store" + { + const req = new Request('https://example.org', { + cf: { cacheTtl: -1 }, + }); + assert.ok(req.cf); + assert.strictEqual(req.cf.cacheTtl, -1); + } + + // cacheTtl: 0 → cacheControl should be synthesized as "max-age=0" + { + const req = new Request('https://example.org', { + cf: { cacheTtl: 0 }, + }); + assert.ok(req.cf); + assert.strictEqual(req.cf.cacheTtl, 0); + } + + // Explicit cacheControl should NOT be overwritten + { + const req = new Request('https://example.org', { + cf: { cacheControl: 'public, s-maxage=86400' }, + }); + assert.ok(req.cf); + assert.strictEqual(req.cf.cacheControl, 'public, s-maxage=86400'); + } + }, +}; + +export const additionalCacheSettings = { + async test(ctrl, env, ctx) { + // All additional cache settings should be accepted on the cf object + { + const req = new Request('https://example.org', { + cf: { + cacheReserveEligible: true, + respectStrongEtag: true, + stripEtags: false, + stripLastModified: false, + cacheDeceptionArmor: true, + cacheReserveMinimumFileSize: 1024, + }, + }); + assert.ok(req.cf); + assert.strictEqual(req.cf.cacheReserveEligible, true); + assert.strictEqual(req.cf.respectStrongEtag, true); + assert.strictEqual(req.cf.stripEtags, false); + assert.strictEqual(req.cf.stripLastModified, false); + assert.strictEqual(req.cf.cacheDeceptionArmor, true); + assert.strictEqual(req.cf.cacheReserveMinimumFileSize, 1024); + } + + // Additional cache settings should work alongside cacheControl + { + const req = new Request('https://example.org', { + cf: { + cacheControl: 'public, max-age=3600', + cacheReserveEligible: true, + stripEtags: true, + }, + }); + assert.ok(req.cf); + assert.strictEqual(req.cf.cacheControl, 'public, max-age=3600'); + assert.strictEqual(req.cf.cacheReserveEligible, true); + assert.strictEqual(req.cf.stripEtags, true); + } + + // Additional cache settings should work alongside cacheTtl + { + const req = new Request('https://example.org', { + cf: { + cacheTtl: 300, + respectStrongEtag: true, + cacheDeceptionArmor: true, + }, + }); + assert.ok(req.cf); + assert.strictEqual(req.cf.cacheTtl, 300); + assert.strictEqual(req.cf.respectStrongEtag, true); + assert.strictEqual(req.cf.cacheDeceptionArmor, true); + } + }, +}; diff --git a/src/workerd/api/tests/cf-cache-control-test.wd-test b/src/workerd/api/tests/cf-cache-control-test.wd-test new file mode 100644 index 00000000000..55c3cce9ef4 --- /dev/null +++ b/src/workerd/api/tests/cf-cache-control-test.wd-test @@ -0,0 +1,28 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "cf-cache-control-test", + worker = ( + modules = [ + ( name = "worker", esModule = embed "cf-cache-control-test.js" ) + ], + bindings = [ + ( name = "CACHE_ENABLED", json = "false" ), + ], + compatibilityFlags = ["nodejs_compat", "cache_option_disabled", "experimental"], + ) + ), + ( name = "cf-cache-control-test-cache-enabled", + worker = ( + modules = [ + ( name = "worker-cache-enabled", esModule = embed "cf-cache-control-test.js" ) + ], + bindings = [ + ( name = "CACHE_ENABLED", json = "true" ), + ], + compatibilityFlags = ["nodejs_compat", "cache_option_enabled", "experimental"], + ) + ), + ], +); diff --git a/types/defines/cf.d.ts b/types/defines/cf.d.ts index 6731b5ba1f5..d7784c0bcb1 100644 --- a/types/defines/cf.d.ts +++ b/types/defines/cf.d.ts @@ -119,6 +119,41 @@ interface RequestInitCfProperties extends Record { * (e.g. { '200-299': 86400, '404': 1, '500-599': 0 }) */ cacheTtlByStatus?: Record; + /** + * Explicit Cache-Control header value to set on the response stored in cache. + * This gives full control over cache directives (e.g. 'public, max-age=3600, s-maxage=86400'). + * + * Cannot be used together with `cacheTtl` or the `cache` request option (`no-store`/`no-cache`), + * as these are mutually exclusive cache control mechanisms. Setting both will throw a TypeError. + * + * Can be used together with `cacheTtlByStatus`. + */ + cacheControl?: string; + /** + * Whether the response should be eligible for Cache Reserve storage. + */ + cacheReserveEligible?: boolean; + /** + * Whether to respect strong ETags (as opposed to weak ETags) from the origin. + */ + respectStrongEtag?: boolean; + /** + * Whether to strip ETag headers from the origin response before caching. + */ + stripEtags?: boolean; + /** + * Whether to strip Last-Modified headers from the origin response before caching. + */ + stripLastModified?: boolean; + /** + * Whether to enable Cache Deception Armor, which protects against web cache + * deception attacks by verifying the Content-Type matches the URL extension. + */ + cacheDeceptionArmor?: boolean; + /** + * Minimum file size in bytes for a response to be eligible for Cache Reserve storage. + */ + cacheReserveMinimumFileSize?: number; scrapeShield?: boolean; apps?: boolean; image?: RequestInitCfPropertiesImage;