diff --git a/Cargo.lock b/Cargo.lock index 983e46d..f4453cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,7 +1314,7 @@ dependencies = [ [[package]] name = "multistore" -version = "0.7.1" +version = "0.7.2" dependencies = [ "async-trait", "base64", @@ -1341,7 +1341,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers" -version = "0.7.1" +version = "0.7.2" dependencies = [ "async-trait", "bytes", @@ -1366,7 +1366,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers-example" -version = "0.7.1" +version = "0.7.2" dependencies = [ "bytes", "console_error_panic_hook", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "multistore-lambda" -version = "0.7.1" +version = "0.7.2" dependencies = [ "bytes", "http", @@ -1411,7 +1411,7 @@ dependencies = [ [[package]] name = "multistore-metering" -version = "0.7.1" +version = "0.7.2" dependencies = [ "bytes", "futures", @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "multistore-oidc-provider" -version = "0.7.1" +version = "0.7.2" dependencies = [ "base64", "chrono", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "multistore-path-mapping" -version = "0.7.1" +version = "0.7.2" dependencies = [ "multistore", "percent-encoding", @@ -1451,7 +1451,7 @@ dependencies = [ [[package]] name = "multistore-server" -version = "0.7.1" +version = "0.7.2" dependencies = [ "axum", "bytes", @@ -1473,7 +1473,7 @@ dependencies = [ [[package]] name = "multistore-static-config" -version = "0.7.1" +version = "0.7.2" dependencies = [ "chrono", "multistore", @@ -1485,7 +1485,7 @@ dependencies = [ [[package]] name = "multistore-sts" -version = "0.7.1" +version = "0.7.2" dependencies = [ "aes-gcm", "base64", diff --git a/crates/cf-workers/src/response.rs b/crates/cf-workers/src/response.rs index 2155abc..e179a49 100644 --- a/crates/cf-workers/src/response.rs +++ b/crates/cf-workers/src/response.rs @@ -4,6 +4,7 @@ //! `web_sys::Response`, including header conversion utilities. use crate::headers::WsHeaders; +use http::header::CACHE_CONTROL; use http::HeaderMap; use multistore::backend::ForwardResponse; use multistore::proxy::GatewayResponse; @@ -29,7 +30,10 @@ pub(crate) fn response_from_proxy_result(result: ProxyResult) -> web_sys::Respon /// Convert a `ForwardResponse` into a `web_sys::Response` /// for the client, preserving the backend's body stream (zero-copy). -pub(crate) fn response_from_forward(resp: ForwardResponse) -> web_sys::Response { +pub(crate) fn response_from_forward( + mut resp: ForwardResponse, +) -> web_sys::Response { + set_no_transform(&mut resp.headers); let resp_init = web_sys::ResponseInit::new(); resp_init.set_status(resp.status); resp_init.set_headers(&WsHeaders::from(&resp.headers).into_inner().into()); @@ -38,6 +42,28 @@ pub(crate) fn response_from_forward(resp: ForwardResponse) -> .unwrap_or_else(|_| error_response(502, "Bad Gateway")) } +/// Add `no-transform` to `Cache-Control` so Cloudflare passes a forwarded +/// object body through unchanged. +/// +/// Otherwise Cloudflare gzips compressible types (e.g. `text/*`) for clients +/// sending `Accept-Encoding: gzip`, which strips `Content-Length` and +/// `Accept-Ranges` from full-object responses. Backend directives are kept. +fn set_no_transform(headers: &mut HeaderMap) { + let value = match headers.get(CACHE_CONTROL).and_then(|v| v.to_str().ok()) { + Some(v) + if v.split(',') + .any(|d| d.trim().eq_ignore_ascii_case("no-transform")) => + { + return + } + Some(v) if !v.trim().is_empty() => format!("{v}, no-transform"), + _ => "no-transform".to_string(), + }; + if let Ok(v) = value.parse() { + headers.insert(CACHE_CONTROL, v); + } +} + /// Build a plain-text error response. pub(crate) fn error_response(status: u16, message: &str) -> web_sys::Response { let init = web_sys::ResponseInit::new(); diff --git a/docs/deployment/cloudflare-workers.md b/docs/deployment/cloudflare-workers.md index 4e5ea0d..5cc21f1 100644 --- a/docs/deployment/cloudflare-workers.md +++ b/docs/deployment/cloudflare-workers.md @@ -9,6 +9,22 @@ The CF Workers runtime deploys the proxy to Cloudflare's edge network. It compil > - **Static config only** — config is supplied inline via the `PROXY_CONFIG` var > - **`SESSION_TOKEN_KEY` required** — Workers are stateless, so sealed tokens are the only way to persist temporary credentials +## Response Handling + +Forwarded object responses (GET/HEAD) carry `Cache-Control: no-transform`, appended to any directives the backend returned. Without it, Cloudflare gzips compressible types such as `text/*` for clients that send `Accept-Encoding: gzip`, which strips `Content-Length` and `Accept-Ranges`, weakens the `ETag` (`W/"…"`), and breaks clients that size or split downloads from a HEAD or send `If-Match`. + +To trade those guarantees for edge compression (e.g. a text-only public mirror), remove the directive from the `web_sys::Response` returned by `into_web_sys()`: + +```rust +let resp = gateway + .handle_request(&parts.as_request_info(), js_body, collect_js_body) + .await + .into_web_sys(); +resp.headers().delete("cache-control")?; +``` + +This drops any backend `Cache-Control` directives too; re-set the header if you need them. + ## Configuration ### `wrangler.toml` diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index d4d80a1..ecbb811 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -177,7 +177,10 @@ def test_put_preserves_content_headers(self): resp = client.get_object(Bucket="private-uploads", Key=key) assert resp["ContentType"] == "application/json" assert resp["ContentDisposition"] == 'attachment; filename="report.json"' - assert resp["CacheControl"] == "max-age=3600" + # The Workers runtime appends `no-transform` to forwarded responses so + # Cloudflare doesn't re-encode the body; the stored directive survives. + directives = [d.strip() for d in resp["CacheControl"].split(",")] + assert directives == ["max-age=3600", "no-transform"] client.delete_object(Bucket="private-uploads", Key=key)