Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 105 additions & 19 deletions src/workerd/api/http.c++
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,31 @@ void Request::shallowCopyHeadersTo(kj::HttpHeaders& out) {
}

kj::Maybe<kj::String> 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);
}

Expand All @@ -687,25 +711,57 @@ kj::Maybe<kj::String> 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<jsg::JsInt32>()) {
auto ttl = KJ_ASSERT_NONNULL(ttlInt.value(js));
obj.set(js, "cacheControl", js.str(kj::str("max-age=", ttl)));
}
}
}

return clone.serialize(js);
Expand All @@ -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));
Expand Down
6 changes: 6 additions & 0 deletions src/workerd/api/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
184 changes: 184 additions & 0 deletions src/workerd/api/tests/cf-cache-control-test.js
Original file line number Diff line number Diff line change
@@ -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);
}
},
};
28 changes: 28 additions & 0 deletions src/workerd/api/tests/cf-cache-control-test.wd-test
Original file line number Diff line number Diff line change
@@ -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"],
)
),
],
);
Loading
Loading