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 @@ -238,6 +238,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)
Comment thread
aram356 marked this conversation as resolved.
}
Ok(PublisherResponse::Buffered(response)) => Ok(response),
Err(e) => {
log::error!("Failed to proxy to publisher origin: {:?}", e);
Expand Down
170 changes: 162 additions & 8 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,21 @@ pub enum PublisherResponse {
/// Parameters for `process_response_streaming`.
params: OwnedProcessResponseParams,
},
/// 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
/// 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,
Comment thread
aram356 marked this conversation as resolved.
Comment thread
aram356 marked this conversation as resolved.
/// Origin body to stream directly to the client.
body: Body,
},
}

/// Owned version of [`ProcessResponseParams`] for returning from
Expand Down Expand Up @@ -319,12 +334,14 @@ 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
/// - 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
///
Expand Down Expand Up @@ -434,15 +451,37 @@ 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 (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();
Comment thread
aram356 marked this conversation as resolved.
Comment thread
aram356 marked this conversation as resolved.
return Ok(PublisherResponse::PassThrough { response, body });
}
Comment thread
aram356 marked this conversation as resolved.

return Ok(PublisherResponse::Buffered(response));
}

Expand Down Expand Up @@ -733,6 +772,121 @@ mod tests {
);
}

#[test]
fn pass_through_gate_streams_non_processable_2xx() {
Comment thread
aram356 marked this conversation as resolved.
Comment thread
aram356 marked this conversation as resolved.
Comment thread
aram356 marked this conversation as resolved.
// 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 (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
&& 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.
// 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;
// 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,
"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