From 43e035c7b6cf362432fba62e805069caee80ffdc 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/7] 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 | 17 +++++++++++++++ crates/trusted-server-core/src/publisher.rs | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 6b7309fe..a8f36e17 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -244,6 +244,23 @@ async fn route_request( Err(e) } } + 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) => { + log::error!("Failed to proxy to publisher origin: {:?}", e); + Err(e) + } + } } Err(e) => Err(e), } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 2c3b2099..1289d8f6 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -274,6 +274,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 @@ -443,6 +456,14 @@ pub fn handle_publisher_request( request_host, response.get_status(), ); + + // 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 948b0ee584be4d04d0ecb7ed96df4b355e7d0c16 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/7] 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 1289d8f6..e8656b9e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -754,6 +754,42 @@ mod tests { ); } + #[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 ec60a9a94b8052133a0da559aff1abea1087b938 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/7] 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 | 46 ++++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index e8656b9e..068d5d54 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -332,12 +332,14 @@ 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 -/// - The response uses a supported `Content-Encoding` (gzip, deflate, br) -/// - 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 reattached unmodified for `send_to_client()`. +/// - [`Stream`](PublisherResponse::Stream) — 2xx processable content with supported +/// `Content-Encoding` and no HTML post-processors. Body piped through the +/// streaming pipeline. +/// - [`Buffered`](PublisherResponse::Buffered) — non-2xx responses, unsupported +/// encoding, or HTML with post-processors that need the full document. /// /// # Errors /// @@ -790,6 +792,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 c3f861078dc4c12d3d5f14448517b3e054df3532 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/7] 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 068d5d54..8c0bd53a 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -280,7 +280,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, @@ -461,8 +462,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 }); } @@ -793,9 +798,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); @@ -803,11 +840,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(); @@ -817,10 +853,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 ef576a19ed8aee942e424f4fd046b77b49f8b816 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/7] 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 | 6 --- crates/trusted-server-core/src/publisher.rs | 48 ++++++++++--------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index a8f36e17..09c0e957 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -238,12 +238,6 @@ async fn route_request( // Response already sent via stream_to_client() return None; } - Ok(PublisherResponse::Buffered(response)) => Ok(response), - Err(e) => { - log::error!("Failed to proxy to publisher origin: {:?}", e); - Err(e) - } - } Ok(PublisherResponse::PassThrough { mut response, body, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 8c0bd53a..66a5b51f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -276,12 +276,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, @@ -462,12 +463,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 }); } @@ -830,9 +831,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); @@ -840,23 +841,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] From fd3384453ab0fe9733d2f9df71a9e035f78bfcdf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:10:58 -0700 Subject: [PATCH 6/7] Address PR #594 review feedback - Fix PassThrough doc comment operation order (set_body before finalize) - Update function doc to describe actual PassThrough flow (set_body + send_to_client, not io::copy) - Remove dead _enters_early_return variable, replace with comment --- crates/trusted-server-adapter-fastly/src/main.rs | 5 +---- crates/trusted-server-core/src/publisher.rs | 11 +++++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 09c0e957..20f2d4cf 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -238,10 +238,7 @@ async fn route_request( // Response already sent via stream_to_client() return None; } - Ok(PublisherResponse::PassThrough { - mut response, - body, - }) => { + 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 diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 66a5b51f..51fa0232 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -275,8 +275,8 @@ pub enum PublisherResponse { params: OwnedProcessResponseParams, }, /// Non-processable 2xx response (images, fonts, video). The caller must: - /// 1. Call `finalize_response()` on the response - /// 2. Reattach the body via `response.set_body(body)` + /// 1. Reattach the body via `response.set_body(body)` + /// 2. Call `finalize_response()` on the response /// 3. Call `response.send_to_client()` /// /// `Content-Length` is preserved — the body is unmodified. Using @@ -819,10 +819,9 @@ mod tests { // 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; + // In production, empty host enters the early-return block via + // `!should_process || request_host.is_empty()`. The PassThrough guard + // checks `is_success && !should_process` — host is irrelevant. let should_pass_through = is_success && !should_process; assert!( should_pass_through, From 6ed2b5e08f6576e7f32ea867ee600011d1ef4f80 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:52:11 -0700 Subject: [PATCH 7/7] Address PR #594 review: PassThrough doc, gate refactor, 205 exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify PassThrough variant doc: finalize_response() and send_to_client() are applied at the outer dispatch level, not in this arm - Hoist status outside the early-return block and reuse is_success to eliminate the duplicate get_status() call - Exclude 205 Reset Content alongside 204 No Content per RFC 9110 §15.3.6; add pass_through_gate_excludes_205_reset_content test - Log binary pass-through before returning to aid production tracing --- crates/trusted-server-core/src/publisher.rs | 53 ++++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 51fa0232..5d364f2f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -274,10 +274,10 @@ pub enum PublisherResponse { /// Parameters for `process_response_streaming`. params: OwnedProcessResponseParams, }, - /// Non-processable 2xx response (images, fonts, video). The caller must: - /// 1. Reattach the body via `response.set_body(body)` - /// 2. Call `finalize_response()` on the response - /// 3. Call `response.send_to_client()` + /// Non-processable 2xx response (images, fonts, video). The adapter must + /// reattach the body via `response.set_body(body)` before returning. + /// `finalize_response()` and `send_to_client()` are applied at the outer + /// response-dispatch level, not in this arm. /// /// `Content-Length` is preserved — the body is unmodified. Using /// `send_to_client()` instead of `stream_to_client()` avoids chunked @@ -451,23 +451,33 @@ pub fn handle_publisher_request( .to_string(); let should_process = is_processable_content_type(&content_type); - let is_success = response.get_status().is_success(); + let status = response.get_status(); + let is_success = status.is_success(); if !should_process || request_host.is_empty() || !is_success { log::debug!( "Skipping response processing - should_process: {}, request_host: '{}', status: {}", should_process, request_host, - response.get_status(), + status, ); // Stream non-processable 2xx responses directly to avoid buffering // large binaries (images, fonts, video) in memory. // 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 { + // Exclude 204 No Content (RFC 9110 §15.3.5) and 205 Reset Content + // (RFC 9110 §15.3.6) — both prohibit a message body. + if is_success + && status != StatusCode::NO_CONTENT + && status != StatusCode::RESET_CONTENT + && !should_process + { + log::debug!( + "Pass-through binary response - Content-Type: '{}', status: {}", + content_type, + status, + ); let body = response.take_body(); return Ok(PublisherResponse::PassThrough { response, body }); } @@ -800,18 +810,35 @@ mod tests { #[test] 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. + // 204 must not have a message body (RFC 9110 §15.3.5); sending one + // would violate the HTTP spec. let status = StatusCode::NO_CONTENT; let should_process = false; - let should_pass_through = - status.is_success() && status != StatusCode::NO_CONTENT && !should_process; + let should_pass_through = status.is_success() + && status != StatusCode::NO_CONTENT + && status != StatusCode::RESET_CONTENT + && !should_process; assert!( !should_pass_through, "204 No Content should not use PassThrough" ); } + #[test] + fn pass_through_gate_excludes_205_reset_content() { + // 205 must not generate content (RFC 9110 §15.3.6). + let status = StatusCode::RESET_CONTENT; + let should_process = false; + let should_pass_through = status.is_success() + && status != StatusCode::NO_CONTENT + && status != StatusCode::RESET_CONTENT + && !should_process; + assert!( + !should_pass_through, + "205 Reset 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.