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
128 changes: 109 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,40 @@ 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");
if (!cacheTtlVal.isUndefined()) {
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.
if (cache != kj::none) {
JSG_REQUIRE(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
176 changes: 176 additions & 0 deletions src/workerd/api/tests/http-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,179 @@ export const cacheMode = {
}
},
};

// 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);
}
},
};
4 changes: 2 additions & 2 deletions src/workerd/api/tests/http-test.wd-test
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const unitTests :Workerd.Config = (
( name = "SERVICE", service = "http-test" ),
( name = "CACHE_ENABLED", json = "false" ),
],
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_disabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "web_socket_auto_reply_to_close"],
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_disabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "web_socket_auto_reply_to_close", "experimental"],
)
),
( name = "http-test-cache-option-enabled",
Expand All @@ -23,7 +23,7 @@ const unitTests :Workerd.Config = (
( name = "SERVICE", service = "http-test-cache-option-enabled" ),
( name = "CACHE_ENABLED", json = "true" ),
],
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_enabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "web_socket_auto_reply_to_close"],
compatibilityFlags = ["nodejs_compat", "service_binding_extra_handlers", "cache_option_enabled", "url_standard", "workers_api_getters_setters_on_prototype", "fetch_legacy_url", "web_socket_auto_reply_to_close", "experimental"],
))
],
);
35 changes: 35 additions & 0 deletions types/defines/cf.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,41 @@ interface RequestInitCfProperties extends Record<string, unknown> {
* (e.g. { '200-299': 86400, '404': 1, '500-599': 0 })
*/
cacheTtlByStatus?: Record<string, number>;
/**
* 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;
Expand Down
Loading