From 7d7b168f20b7b02d5e0c27acac52f4214ef7bf27 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:48:12 -0700 Subject: [PATCH 1/5] Stream binary pass-through responses via io::copy Non-processable 2xx responses (images, fonts, video) now stream directly to the client via PublisherResponse::PassThrough instead of buffering the entire body in memory. Content-Length is preserved since the body is unmodified. --- .../trusted-server-adapter-fastly/src/main.rs | 15 +++++++++++++ crates/trusted-server-core/src/publisher.rs | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index bf90880f..0bbfb52f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -183,6 +183,21 @@ async fn route_request( // Response already sent via stream_to_client() return None; } + Ok(PublisherResponse::PassThrough { + mut response, + mut body, + }) => { + // Binary pass-through: stream body directly without processing. + finalize_response(settings, geo_info.as_ref(), &mut response); + let mut streaming_body = response.stream_to_client(); + if let Err(e) = std::io::copy(&mut body, &mut streaming_body) { + log::error!("Binary pass-through streaming failed: {e}"); + drop(streaming_body); + } else if let Err(e) = streaming_body.finish() { + log::error!("Failed to finish pass-through body: {e}"); + } + return None; + } Ok(PublisherResponse::Buffered(response)) => Ok(response), Err(e) => { log::error!("Failed to proxy to publisher origin: {:?}", e); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c6350e5c..db619dea 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -198,6 +198,19 @@ pub enum PublisherResponse { /// Parameters for `process_response_streaming`. params: OwnedProcessResponseParams, }, + /// Non-processable 2xx response (images, fonts, video). The caller must: + /// 1. Call `finalize_response()` on the response + /// 2. Call `response.stream_to_client()` to get a `StreamingBody` + /// 3. Copy body bytes directly via `io::copy(&mut body, &mut streaming_body)` + /// 4. Call `StreamingBody::finish()` + /// + /// `Content-Length` is preserved since the body is unmodified. + PassThrough { + /// Response with all headers set but body not yet written. + response: Response, + /// Origin body to stream directly to the client. + body: Body, + }, } /// Owned version of [`ProcessResponseParams`] for returning from @@ -360,6 +373,14 @@ pub fn handle_publisher_request( should_process, request_host ); + + // Stream non-processable 2xx responses directly to avoid buffering + // large binaries (images, fonts, video) in memory. + if response.get_status().is_success() && !should_process { + let body = response.take_body(); + return Ok(PublisherResponse::PassThrough { response, body }); + } + return Ok(PublisherResponse::Buffered(response)); } From 69da54fefa40724a45bde4915cd1012860277922 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:49:36 -0700 Subject: [PATCH 2/5] Add pass-through gate tests for binary streaming Tests verify non-processable 2xx responses return PassThrough, non-processable errors stay Buffered, and processable content goes through Stream (not PassThrough). --- crates/trusted-server-core/src/publisher.rs | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index db619dea..7ee8e4db 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -632,6 +632,42 @@ mod tests { assert!(!can_stream, "should not stream 4xx/5xx JSON responses"); } + #[test] + fn pass_through_gate_streams_non_processable_2xx() { + // Non-processable (image) + 2xx → PassThrough + let should_process = false; + let is_success = true; + let should_pass_through = is_success && !should_process; + assert!( + should_pass_through, + "should pass-through non-processable 2xx responses (images, fonts)" + ); + } + + #[test] + fn pass_through_gate_buffers_non_processable_error() { + // Non-processable (image) + 4xx → Buffered + let should_process = false; + let is_success = false; + let should_pass_through = is_success && !should_process; + assert!( + !should_pass_through, + "should buffer non-processable error responses" + ); + } + + #[test] + fn pass_through_gate_does_not_apply_to_processable_content() { + // Processable (HTML) + 2xx → Stream (not PassThrough) + let should_process = true; + let is_success = true; + let should_pass_through = is_success && !should_process; + assert!( + !should_pass_through, + "processable content should go through Stream, not PassThrough" + ); + } + #[test] fn test_content_encoding_detection() { // Test that we properly handle responses with various content encodings From c378f0153c470dd282e6a1a59d87940a3306304a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:54:48 -0700 Subject: [PATCH 3/5] Add byte-level pass-through test and update doc comment Adds pass_through_preserves_body_and_content_length test that verifies io::copy produces identical output and Content-Length is preserved. Updates handle_publisher_request doc to describe all three response variants. --- crates/trusted-server-core/src/publisher.rs | 44 ++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 7ee8e4db..4fe9de2c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -256,11 +256,13 @@ pub fn stream_publisher_body( /// Proxies requests to the publisher's origin server. /// -/// Returns a [`PublisherResponse`] indicating whether the response can be -/// streamed or must be sent buffered. The streaming path is chosen when: -/// - The backend returns a 2xx status -/// - The response has a processable content type -/// - No HTML post-processors are registered (the streaming gate) +/// Returns a [`PublisherResponse`] indicating how the response should be sent: +/// - [`PassThrough`](PublisherResponse::PassThrough) — 2xx non-processable content +/// (images, fonts, video). Body streamed directly via `io::copy`. +/// - [`Stream`](PublisherResponse::Stream) — 2xx processable content without HTML +/// post-processors. Body piped through the streaming pipeline. +/// - [`Buffered`](PublisherResponse::Buffered) — non-2xx responses, or HTML with +/// post-processors that need the full document. /// /// # Errors /// @@ -668,6 +670,38 @@ mod tests { ); } + #[test] + fn pass_through_preserves_body_and_content_length() { + // Simulate the PassThrough path: take body from response, io::copy to output. + // Verify byte-for-byte identity and that Content-Length is preserved. + let image_bytes: Vec = (0..=255).cycle().take(4096).collect(); + + let mut response = Response::from_status(StatusCode::OK); + response.set_header("content-type", "image/png"); + response.set_header("content-length", image_bytes.len().to_string()); + response.set_body(Body::from(image_bytes.clone())); + + // Simulate PassThrough: take body, preserve Content-Length + let content_length = response + .get_header_str("content-length") + .map(str::to_string); + let mut body = response.take_body(); + + // io::copy into a Vec (simulating StreamingBody) + let mut output = Vec::new(); + std::io::copy(&mut body, &mut output).expect("should copy body"); + + assert_eq!( + output, image_bytes, + "pass-through should preserve body byte-for-byte" + ); + assert_eq!( + content_length.as_deref(), + Some("4096"), + "Content-Length should be preserved for pass-through" + ); + } + #[test] fn test_content_encoding_detection() { // Test that we properly handle responses with various content encodings From 75c1e7c1b40372ac14a9aa65df2468a576ac9676 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:02:14 -0700 Subject: [PATCH 4/5] Fix PassThrough edge cases from self-review - Exclude 204 No Content from PassThrough (must not have body) - Remove Content-Length before streaming (stream_to_client uses chunked encoding, keeping both violates HTTP spec) - Add tests for 204 exclusion and empty-host interaction - Update doc comment and byte-level test to reflect CL removal --- crates/trusted-server-core/src/publisher.rs | 61 ++++++++++++++++----- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4fe9de2c..409a9b95 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -204,7 +204,8 @@ pub enum PublisherResponse { /// 3. Copy body bytes directly via `io::copy(&mut body, &mut streaming_body)` /// 4. Call `StreamingBody::finish()` /// - /// `Content-Length` is preserved since the body is unmodified. + /// `Content-Length` is removed because `stream_to_client()` uses chunked + /// transfer encoding. The body content is unmodified. PassThrough { /// Response with all headers set but body not yet written. response: Response, @@ -378,8 +379,12 @@ pub fn handle_publisher_request( // Stream non-processable 2xx responses directly to avoid buffering // large binaries (images, fonts, video) in memory. - if response.get_status().is_success() && !should_process { + // Exclude 204 No Content — it must not have a message body, and + // stream_to_client() would add chunked Transfer-Encoding. + let status = response.get_status(); + if status.is_success() && status != StatusCode::NO_CONTENT && !should_process { let body = response.take_body(); + response.remove_header(header::CONTENT_LENGTH); return Ok(PublisherResponse::PassThrough { response, body }); } @@ -671,9 +676,41 @@ mod tests { } #[test] - fn pass_through_preserves_body_and_content_length() { - // Simulate the PassThrough path: take body from response, io::copy to output. - // Verify byte-for-byte identity and that Content-Length is preserved. + fn pass_through_gate_excludes_204_no_content() { + // 204 must not have a message body; stream_to_client would add + // chunked Transfer-Encoding which violates HTTP spec. + let status = StatusCode::NO_CONTENT; + let should_process = false; + let should_pass_through = + status.is_success() && status != StatusCode::NO_CONTENT && !should_process; + assert!( + !should_pass_through, + "204 No Content should not use PassThrough" + ); + } + + #[test] + fn pass_through_gate_applies_with_empty_request_host() { + // Non-processable 2xx with empty request_host still gets PassThrough. + // The empty-host path only blocks processing (URL rewriting needs a host); + // pass-through doesn't process, so the host is irrelevant. + let should_process = false; + let is_success = true; + let request_host_empty = true; + // In production: enters the `!should_process || request_host.is_empty()` block, + // then the PassThrough guard checks `is_success && !should_process` — host irrelevant. + let _enters_early_return = !should_process || request_host_empty; + let should_pass_through = is_success && !should_process; + assert!( + should_pass_through, + "non-processable 2xx with empty host should still pass-through" + ); + } + + #[test] + fn pass_through_preserves_body_and_removes_content_length() { + // Simulate the PassThrough path: take body, remove Content-Length, + // io::copy to output. Verify byte-for-byte identity. let image_bytes: Vec = (0..=255).cycle().take(4096).collect(); let mut response = Response::from_status(StatusCode::OK); @@ -681,11 +718,10 @@ mod tests { response.set_header("content-length", image_bytes.len().to_string()); response.set_body(Body::from(image_bytes.clone())); - // Simulate PassThrough: take body, preserve Content-Length - let content_length = response - .get_header_str("content-length") - .map(str::to_string); + // Simulate PassThrough: take body, remove Content-Length + // (stream_to_client uses chunked encoding) let mut body = response.take_body(); + response.remove_header(header::CONTENT_LENGTH); // io::copy into a Vec (simulating StreamingBody) let mut output = Vec::new(); @@ -695,10 +731,9 @@ mod tests { output, image_bytes, "pass-through should preserve body byte-for-byte" ); - assert_eq!( - content_length.as_deref(), - Some("4096"), - "Content-Length should be preserved for pass-through" + assert!( + response.get_header(header::CONTENT_LENGTH).is_none(), + "Content-Length should be removed for streaming pass-through" ); } From 3aa88fe16282d5fc1760558c209471c1df447e85 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:52:48 -0700 Subject: [PATCH 5/5] Preserve Content-Length for PassThrough by using send_to_client PassThrough reattaches the unmodified body and uses send_to_client() instead of stream_to_client() + io::copy. This preserves Content-Length (avoids chunked encoding overhead for images/fonts) and lets Fastly stream from its internal buffer without WASM memory buffering. --- .../trusted-server-adapter-fastly/src/main.rs | 21 +++----- crates/trusted-server-core/src/publisher.rs | 48 ++++++++++--------- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 0bbfb52f..9791dd6e 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -183,20 +183,13 @@ async fn route_request( // Response already sent via stream_to_client() return None; } - Ok(PublisherResponse::PassThrough { - mut response, - mut body, - }) => { - // Binary pass-through: stream body directly without processing. - finalize_response(settings, geo_info.as_ref(), &mut response); - let mut streaming_body = response.stream_to_client(); - if let Err(e) = std::io::copy(&mut body, &mut streaming_body) { - log::error!("Binary pass-through streaming failed: {e}"); - drop(streaming_body); - } else if let Err(e) = streaming_body.finish() { - log::error!("Failed to finish pass-through body: {e}"); - } - return None; + Ok(PublisherResponse::PassThrough { mut response, body }) => { + // Binary pass-through: reattach body and send via send_to_client(). + // This preserves Content-Length and avoids chunked encoding overhead. + // Fastly streams the body from its internal buffer — no WASM + // memory buffering occurs. + response.set_body(body); + Ok(response) } Ok(PublisherResponse::Buffered(response)) => Ok(response), Err(e) => { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 409a9b95..a216c9e5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -200,12 +200,13 @@ pub enum PublisherResponse { }, /// Non-processable 2xx response (images, fonts, video). The caller must: /// 1. Call `finalize_response()` on the response - /// 2. Call `response.stream_to_client()` to get a `StreamingBody` - /// 3. Copy body bytes directly via `io::copy(&mut body, &mut streaming_body)` - /// 4. Call `StreamingBody::finish()` + /// 2. Reattach the body via `response.set_body(body)` + /// 3. Call `response.send_to_client()` /// - /// `Content-Length` is removed because `stream_to_client()` uses chunked - /// transfer encoding. The body content is unmodified. + /// `Content-Length` is preserved — the body is unmodified. Using + /// `send_to_client()` instead of `stream_to_client()` avoids chunked + /// encoding overhead. Fastly streams the body from its internal buffer + /// without copying into WASM memory. PassThrough { /// Response with all headers set but body not yet written. response: Response, @@ -379,12 +380,12 @@ pub fn handle_publisher_request( // Stream non-processable 2xx responses directly to avoid buffering // large binaries (images, fonts, video) in memory. - // Exclude 204 No Content — it must not have a message body, and - // stream_to_client() would add chunked Transfer-Encoding. + // Content-Length is preserved — the body is unmodified, so the + // browser knows the exact size for progress/layout. + // Exclude 204 No Content — it must not have a message body. let status = response.get_status(); if status.is_success() && status != StatusCode::NO_CONTENT && !should_process { let body = response.take_body(); - response.remove_header(header::CONTENT_LENGTH); return Ok(PublisherResponse::PassThrough { response, body }); } @@ -708,9 +709,9 @@ mod tests { } #[test] - fn pass_through_preserves_body_and_removes_content_length() { - // Simulate the PassThrough path: take body, remove Content-Length, - // io::copy to output. Verify byte-for-byte identity. + fn pass_through_preserves_body_and_content_length() { + // Simulate the PassThrough path: take body, reattach, send. + // Verify byte-for-byte identity and Content-Length preservation. let image_bytes: Vec = (0..=255).cycle().take(4096).collect(); let mut response = Response::from_status(StatusCode::OK); @@ -718,23 +719,24 @@ mod tests { response.set_header("content-length", image_bytes.len().to_string()); response.set_body(Body::from(image_bytes.clone())); - // Simulate PassThrough: take body, remove Content-Length - // (stream_to_client uses chunked encoding) - let mut body = response.take_body(); - response.remove_header(header::CONTENT_LENGTH); - - // io::copy into a Vec (simulating StreamingBody) - let mut output = Vec::new(); - std::io::copy(&mut body, &mut output).expect("should copy body"); + // Simulate PassThrough: take body then reattach + let body = response.take_body(); + // Body is unmodified — Content-Length stays correct + assert_eq!( + response + .get_header_str("content-length") + .expect("should have content-length"), + "4096", + "Content-Length should be preserved for pass-through" + ); + // Reattach and verify body content + response.set_body(body); + let output = response.into_body().into_bytes(); assert_eq!( output, image_bytes, "pass-through should preserve body byte-for-byte" ); - assert!( - response.get_header(header::CONTENT_LENGTH).is_none(), - "Content-Length should be removed for streaming pass-through" - ); } #[test]