Skip to content
Open
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
8 changes: 8 additions & 0 deletions crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
138 changes: 133 additions & 5 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -243,11 +258,13 @@ pub fn stream_publisher_body<W: Write>(

/// 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
///
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -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<u8> = (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
Expand Down
Loading