Skip to content
Merged
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
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 27 additions & 1 deletion crates/cf-workers/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +30,10 @@ pub(crate) fn response_from_proxy_result(result: ProxyResult) -> web_sys::Respon

/// Convert a `ForwardResponse<web_sys::Response>` 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>) -> web_sys::Response {
pub(crate) fn response_from_forward(
mut resp: ForwardResponse<web_sys::Response>,
) -> 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());
Expand All @@ -38,6 +42,28 @@ pub(crate) fn response_from_forward(resp: ForwardResponse<web_sys::Response>) ->
.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();
Expand Down
16 changes: 16 additions & 0 deletions docs/deployment/cloudflare-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
5 changes: 4 additions & 1 deletion tests/integration/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading