diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index bf90880f..9791dd6e 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -183,6 +183,14 @@ async fn route_request( // Response already sent via stream_to_client() 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) => { 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..a216c9e5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -198,6 +198,21 @@ 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. Reattach the body via `response.set_body(body)` + /// 3. Call `response.send_to_client()` + /// + /// `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, + /// Origin body to stream directly to the client. + body: Body, + }, } /// Owned version of [`ProcessResponseParams`] for returning from @@ -243,11 +258,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 /// @@ -360,6 +377,18 @@ 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. + // 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(); + return Ok(PublisherResponse::PassThrough { response, body }); + } + return Ok(PublisherResponse::Buffered(response)); } @@ -611,6 +640,105 @@ 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 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_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); + 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 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" + ); + } + #[test] fn test_content_encoding_detection() { // Test that we properly handle responses with various content encodings