From 4c82bca84e14797565113c3c8f8e41e73088ddbf Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 9 Jul 2026 16:00:16 -0500 Subject: [PATCH 1/2] Add opt-in SSAT compression offload --- crates/trusted-server-adapter-axum/src/app.rs | 8 +- .../src/app.rs | 10 +- .../trusted-server-adapter-fastly/src/app.rs | 6 +- crates/trusted-server-adapter-spin/src/app.rs | 10 +- crates/trusted-server-core/src/publisher.rs | 619 +++++++++++++++++- crates/trusted-server-core/src/settings.rs | 41 +- docs/guide/configuration.md | 83 ++- trusted-server.example.toml | 3 + 8 files changed, 711 insertions(+), 69 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 170654e2..25ae4716 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -20,7 +20,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + AuctionDispatch, DeliveryCompressionCapability, PublisherAdapterOptions, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ @@ -216,7 +217,10 @@ async fn dispatch_fallback( &mut ec_context, auction, req, - EdgeCacheHeader::SMaxageFallback, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SMaxageFallback, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await?; // Async finalize so the dispatched auction is collected and its bids are diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 015f1e7f..9488446f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -22,8 +22,9 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PublisherResponse, buffer_publisher_response_async, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, DeliveryCompressionCapability, PublisherAdapterOptions, PublisherResponse, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -406,7 +407,10 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, - EdgeCacheHeader::CloudflareCdnCacheControl, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::CloudflareCdnCacheControl, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 9abbe7d6..32efb8d3 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -119,6 +119,7 @@ use trusted_server_core::proxy::{ use trusted_server_core::publisher::{ buffer_publisher_response_async, handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch, + DeliveryCompressionCapability, PublisherAdapterOptions, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -795,7 +796,10 @@ async fn dispatch_fallback( &mut ec.ec_context, auction, req, - EdgeCacheHeader::SurrogateControl, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, ) .await { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 63cbe6bc..c8e8d164 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -21,8 +21,9 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_sign, }; use trusted_server_core::publisher::{ - AuctionDispatch, PublisherResponse, buffer_publisher_response_async, handle_page_bids, - handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + AuctionDispatch, DeliveryCompressionCapability, PublisherAdapterOptions, PublisherResponse, + buffer_publisher_response_async, handle_page_bids, handle_publisher_request, + handle_tsjs_dynamic, page_bids_preflight_denied, }; use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, @@ -672,7 +673,10 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, - EdgeCacheHeader::SMaxageFallback, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SMaxageFallback, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1262676d..669d5631 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -101,6 +101,20 @@ fn restrict_accept_encoding(req: &mut Request) { ); } +fn ensure_vary_accept_encoding(headers: &mut http::HeaderMap) { + let already_varies = headers.get_all(header::VARY).iter().any(|value| { + value.to_str().ok().is_some_and(|value| { + value.split(',').any(|token| { + let token = token.trim(); + token == "*" || token.eq_ignore_ascii_case("accept-encoding") + }) + }) + }); + if !already_varies { + headers.append(header::VARY, HeaderValue::from_static("Accept-Encoding")); + } +} + fn select_supported_accept_encoding(client_accept_encoding: &str) -> String { let supported_subset = SUPPORTED_ENCODING_VALUES .into_iter() @@ -254,7 +268,8 @@ fn parse_deferred_module_filename(filename: &str) -> Option<&'static str> { /// Parameters for processing response streaming. struct ProcessResponseParams<'a> { - content_encoding: &'a str, + input_compression: Compression, + output_compression: Compression, origin_host: &'a str, origin_url: &'a str, request_host: &'a str, @@ -285,17 +300,17 @@ fn process_response_streaming( let is_rsc_flight = content_type_contains_ascii_case_insensitive(params.content_type, "text/x-component"); log::debug!( - "process_response_streaming: content_type={}, content_encoding={}, is_html={}, is_rsc_flight={}", + "process_response_streaming: content_type={}, input_compression={:?}, output_compression={:?}, is_html={}, is_rsc_flight={}", params.content_type, - params.content_encoding, + params.input_compression, + params.output_compression, is_html, is_rsc_flight ); - let compression = Compression::from_content_encoding(params.content_encoding); let config = PipelineConfig { - input_compression: compression, - output_compression: compression, + input_compression: params.input_compression, + output_compression: params.output_compression, chunk_size: 8192, }; @@ -530,7 +545,8 @@ fn apply_publisher_asset_cache_policy( /// Owned version of [`ProcessResponseParams`] for returning from /// [`handle_publisher_request`] without lifetime issues. pub struct OwnedProcessResponseParams { - pub(crate) content_encoding: String, + pub(crate) input_compression: Compression, + pub(crate) output_compression: Compression, pub(crate) origin_host: String, pub(crate) origin_url: String, pub(crate) request_host: String, @@ -707,7 +723,8 @@ pub fn stream_publisher_body( integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { let borrowed = ProcessResponseParams { - content_encoding: ¶ms.content_encoding, + input_compression: params.input_compression, + output_compression: params.output_compression, origin_host: ¶ms.origin_host, origin_url: ¶ms.origin_url, request_host: ¶ms.request_host, @@ -843,12 +860,12 @@ pub async fn stream_publisher_body_async( } }; - let compression = Compression::from_content_encoding(¶ms.content_encoding); stream_html_with_auction_hold( body, output, &mut processor, - compression, + params.input_compression, + params.output_compression, AuctionCollectCtx { dispatched, telemetry, @@ -893,10 +910,9 @@ async fn buffer_html_late_binding_with_postprocessors( }; use crate::integrations::IntegrationDocumentState; - let compression = Compression::from_content_encoding(&ctx.params.content_encoding); let decoded = match decode_body_to_vec( body, - compression, + ctx.params.input_compression, ctx.settings.publisher.max_buffered_body_bytes, ) { Ok(decoded) => decoded, @@ -1032,7 +1048,7 @@ async fn buffer_html_late_binding_with_postprocessors( message: "Failed to post-process buffered publisher HTML".to_string(), })?; - encode_processed_html_to_writer(&post_processed, compression, output) + encode_processed_html_to_writer(&post_processed, ctx.params.output_compression, output) } fn decode_body_to_vec( @@ -1337,7 +1353,8 @@ async fn stream_html_with_auction_hold( body: EdgeBody, output: &mut W, processor: &mut P, - compression: Compression, + input_compression: Compression, + output_compression: Compression, ctx: AuctionCollectCtx<'_>, late_binding: LateBindingStreamConfig<'_>, ) -> Result<(), Report> { @@ -1351,8 +1368,8 @@ async fn stream_html_with_auction_hold( let tracker = late_binding.tracker; let fallback_ctx = late_binding.fallback; let body = body_as_reader(body); - match compression { - Compression::None => { + match (input_compression, output_compression) { + (Compression::None, Compression::None) => { body_close_hold_loop( body, output, @@ -1364,7 +1381,7 @@ async fn stream_html_with_auction_hold( ) .await } - Compression::Gzip => { + (Compression::Gzip, Compression::Gzip) => { let decoder = GzDecoder::new(body); let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); body_close_hold_loop( @@ -1382,7 +1399,7 @@ async fn stream_html_with_auction_hold( })?; Ok(()) } - Compression::Deflate => { + (Compression::Deflate, Compression::Deflate) => { let decoder = ZlibDecoder::new(body); let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); body_close_hold_loop( @@ -1400,7 +1417,7 @@ async fn stream_html_with_auction_hold( })?; Ok(()) } - Compression::Brotli => { + (Compression::Brotli, Compression::Brotli) => { let decoder = Decompressor::new(body, STREAM_CHUNK_SIZE); let params = BrotliEncoderParams { quality: 4, @@ -1422,6 +1439,45 @@ async fn stream_html_with_auction_hold( let _ = encoder.into_inner(); Ok(()) } + (Compression::Gzip, Compression::None) => { + body_close_hold_loop( + GzDecoder::new(body), + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } + (Compression::Deflate, Compression::None) => { + body_close_hold_loop( + ZlibDecoder::new(body), + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } + (Compression::Brotli, Compression::None) => { + body_close_hold_loop( + Decompressor::new(body, STREAM_CHUNK_SIZE), + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } + _ => Err(Report::new(TrustedServerError::Proxy { + message: "Unsupported compression transformation".to_string(), + })), } } @@ -1821,6 +1877,41 @@ async fn collect_stream_auction( } } +/// Delivery-compression behavior explicitly supported by an adapter. +/// +/// This capability is independent of [`EdgeCacheHeader`]. Adapters must declare +/// it directly so cache-header selection cannot accidentally enable Fastly-only +/// response delivery behavior. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum DeliveryCompressionCapability { + /// Fastly dynamic compression honors `X-Compress-Hint: on` at delivery. + FastlyDynamic, + /// The adapter has no compatible delivery-compression facility. + Unavailable, +} + +/// Adapter-specific publisher response behavior. +/// +/// Bundles cache-header selection with an explicit delivery-compression +/// capability while keeping those independent platform concerns visible. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct PublisherAdapterOptions { + /// Shared-cache header family emitted by publisher cache policies. + pub edge_cache_header: EdgeCacheHeader, + /// Delivery-compression facility available on the current adapter. + pub delivery_compression: DeliveryCompressionCapability, +} + +fn should_offload_ssat_compression( + settings: &Settings, + adapter_options: PublisherAdapterOptions, + should_run_ad_stack: bool, +) -> bool { + settings.publisher.ssat_compression_offload_enabled + && adapter_options.delivery_compression == DeliveryCompressionCapability::FastlyDynamic + && should_run_ad_stack +} + /// Auction dispatch context passed to [`handle_publisher_request`]. pub struct AuctionDispatch<'a> { /// Orchestrator that dispatches and collects SSP bid requests. @@ -1852,9 +1943,10 @@ pub async fn handle_publisher_request( ec_context: &mut EcContext, auction: AuctionDispatch<'_>, mut req: Request, - edge_header: EdgeCacheHeader, + adapter_options: PublisherAdapterOptions, ) -> Result> { log::debug!("Proxying request to publisher_origin"); + let edge_header = adapter_options.edge_cache_header; // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. @@ -1965,6 +2057,8 @@ pub async fn handle_publisher_request( auction.orchestrator.is_enabled(), ); let should_run_auction = should_run_ad_stack; + let compression_offload_active = + should_offload_ssat_compression(settings, adapter_options, should_run_ad_stack); // Diagnostic: shows which gate suppresses the server-side auction. Pair with // the `EC context: ... jurisdiction=...` line from EC-context construction // when `consent_allows_auction=false`. @@ -2130,8 +2224,18 @@ pub async fn handle_publisher_request( } ); - // Only advertise encodings the rewrite pipeline can decode and re-encode. - restrict_accept_encoding(&mut req); + if compression_offload_active { + // Ask the origin for identity so Trusted Server can emit uncompressed + // HTML for Fastly's dynamic delivery compression. The response path + // still decodes supported compression if the origin ignores this. + req.headers_mut().insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + } else { + // Only advertise encodings the rewrite pipeline can decode and re-encode. + restrict_accept_encoding(&mut req); + } // Strip the internal `fastly-ssl` scheme signal before forwarding to the // origin. On the EdgeZero path the entry point re-injects this header from // trusted Fastly TLS metadata so in-process scheme detection works; the @@ -2303,14 +2407,29 @@ pub async fn handle_publisher_request( content_encoding ); + let offload_stream = compression_offload_active && is_html_content_type(&content_type); + let input_compression = Compression::from_content_encoding(&content_encoding); + let output_compression = if offload_stream { + Compression::None + } else { + input_compression + }; let body = std::mem::replace(response.body_mut(), EdgeBody::empty()); response.headers_mut().remove(header::CONTENT_LENGTH); + if offload_stream { + response.headers_mut().remove(header::CONTENT_ENCODING); + response + .headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); + ensure_vary_accept_encoding(response.headers_mut()); + } Ok(PublisherResponse::Stream { response, body, params: Box::new(OwnedProcessResponseParams { - content_encoding, + input_compression, + output_compression, origin_host, origin_url: settings.publisher.origin_url.clone(), request_host: request_host.to_string(), @@ -2984,7 +3103,7 @@ mod tests { use brotli::enc::writer::CompressorWriter; use brotli::Decompressor; use flate2::read::GzDecoder; - use flate2::write::GzEncoder; + use flate2::write::{GzEncoder, ZlibEncoder}; use super::*; use crate::auction::types::{AdFormat, AdSlot, MediaType}; @@ -3014,6 +3133,14 @@ mod tests { output } + fn deflate_encode(input: &[u8]) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(input) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + } + fn brotli_encode(input: &[u8]) -> Vec { let mut encoder = CompressorWriter::new(Vec::new(), 4096, 5, 22); encoder @@ -3036,7 +3163,8 @@ mod tests { content_encoding: &str, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { - content_encoding: content_encoding.to_owned(), + input_compression: Compression::from_content_encoding(content_encoding), + output_compression: Compression::from_content_encoding(content_encoding), origin_host: settings.publisher.origin_host(), origin_url: settings.publisher.origin_url.clone(), request_host: settings.publisher.domain.clone(), @@ -3056,7 +3184,8 @@ mod tests { dispatched_auction: Option, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: settings.publisher.origin_host(), origin_url: settings.publisher.origin_url.clone(), request_host: settings.publisher.domain.clone(), @@ -3172,6 +3301,416 @@ mod tests { ); } + fn ssat_compression_test_settings(enabled: bool) -> Settings { + let mut settings = Settings::from_toml(&format!( + r#"{} + + [auction] + enabled = true + + [creative_opportunities] + gam_network_id = "12345" + + [[creative_opportunities.slot]] + id = "article-slot" + page_patterns = ["/article"] + formats = [{{ width = 300, height = 250 }}] + "#, + crate_test_settings_str() + )) + .expect("should parse SSAT compression test settings"); + settings.publisher.ssat_compression_offload_enabled = enabled; + settings.proxy.allowed_domains = vec!["*.example".to_string(), "*.example.com".to_string()]; + settings + } + + async fn run_ssat_compression_request( + settings: &Settings, + adapter_options: PublisherAdapterOptions, + origin_body: Vec, + origin_content_encoding: Option<&str>, + ) -> ( + PublisherResponse, + Arc, + RuntimeServices, + AuctionOrchestrator, + ) { + let stub = Arc::new(StubHttpClient::new()); + let mut origin_headers = vec![ + ( + header::CONTENT_TYPE.as_str().to_string(), + "text/html; charset=utf-8".to_string(), + ), + ( + header::CONTENT_LENGTH.as_str().to_string(), + "9999".to_string(), + ), + (header::VARY.as_str().to_string(), "Origin".to_string()), + ( + header::VARY.as_str().to_string(), + "Accept-Language".to_string(), + ), + ]; + if let Some(origin_content_encoding) = origin_content_encoding { + origin_headers.push(( + header::CONTENT_ENCODING.as_str().to_string(), + origin_content_encoding.to_string(), + )); + } + stub.push_response_with_headers(200, origin_body, origin_headers); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test_with_ip(None, consent, None); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article?edition=morning&view=full") + .header(header::HOST, "publisher.example") + .header(header::ACCEPT_ENCODING, "gzip, br") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build SSAT compression request"); + + let publisher_response = handle_publisher_request( + settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: settings.creative_opportunity_slots(), + registry: None, + }, + req, + adapter_options, + ) + .await + .expect("should proxy SSAT compression request"); + + (publisher_response, stub, services, orchestrator) + } + + #[test] + fn ssat_compression_offload_requires_setting_capability_and_ad_stack() { + let mut settings = create_test_settings(); + let fastly_options = PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }; + let unavailable_options = PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SMaxageFallback, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }; + + assert!( + !should_offload_ssat_compression(&settings, fastly_options, true), + "disabled setting should retain in-guest compression" + ); + settings.publisher.ssat_compression_offload_enabled = true; + assert!( + !should_offload_ssat_compression(&settings, unavailable_options, true), + "adapters without Fastly dynamic compression should retain in-guest compression" + ); + assert!( + !should_offload_ssat_compression(&settings, fastly_options, false), + "requests outside the server-side ad stack should retain in-guest compression" + ); + assert!( + should_offload_ssat_compression(&settings, fastly_options, true), + "enabled Fastly server-side ad stack should offload compression" + ); + } + + #[test] + fn ensure_vary_accept_encoding_preserves_wildcard() { + let mut headers = http::HeaderMap::new(); + headers.insert(header::VARY, HeaderValue::from_static("*")); + + ensure_vary_accept_encoding(&mut headers); + + assert_eq!( + headers + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(), + vec!["*"], + "wildcard Vary should not receive a redundant Accept-Encoding field" + ); + } + + #[tokio::test] + async fn fastly_ssat_compression_offload_requests_and_emits_identity() { + let settings = ssat_compression_test_settings(true); + let html = b"

Origin HTML

"; + let (publisher_response, stub, services, orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + html.to_vec(), + None, + ) + .await; + + assert_eq!( + stub.recorded_request_uris(), + vec!["https://origin.test-publisher.com/article?edition=morning&view=full"], + "offload must preserve the publisher request path and query" + ); + let request_headers = stub.recorded_request_headers(); + assert!( + request_headers[0].iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "identity" + }), + "Fastly SSAT offload must request identity from the origin" + ); + + let PublisherResponse::Stream { + response, params, .. + } = &publisher_response + else { + panic!("processable SSAT HTML should use the stream route"); + }; + assert_eq!(params.input_compression, Compression::None); + assert_eq!(params.output_compression, Compression::None); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, max-age=0"), + "compression offload must preserve SSAT browser privacy" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "compression offload must not restore shared cacheability" + ); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + assert!(response.headers().get(header::CONTENT_LENGTH).is_none()); + assert_eq!( + response + .headers() + .get(HEADER_X_COMPRESS_HINT) + .and_then(|value| value.to_str().ok()), + Some("on") + ); + let vary = response + .headers() + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(); + assert_eq!( + vary, + vec!["Origin", "Accept-Language", "Accept-Encoding"], + "offload must preserve existing Vary fields and add Accept-Encoding once" + ); + + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer identity SSAT output"); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + let body = response + .into_body() + .into_bytes() + .expect("should read identity SSAT output"); + let body = String::from_utf8(body.to_vec()).expect("should decode identity SSAT output"); + assert!(body.contains("Origin HTML"), "should preserve origin HTML"); + assert!(body.contains("tsjs"), "should still process SSAT HTML"); + } + + #[tokio::test] + async fn fastly_ssat_compression_offload_decodes_supported_origin_encodings() { + let settings = ssat_compression_test_settings(true); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let html = b"

Compressed origin HTML

"; + let cases = [ + ("gzip", gzip_encode(html)), + ("deflate", deflate_encode(html)), + ("br", brotli_encode(html)), + ]; + + for (origin_content_encoding, origin_body) in cases { + let (publisher_response, _stub, services, orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + origin_body, + Some(origin_content_encoding), + ) + .await; + + let PublisherResponse::Stream { + response, params, .. + } = &publisher_response + else { + panic!("supported compressed SSAT HTML should use the stream route"); + }; + assert_eq!( + params.input_compression, + Compression::from_content_encoding(origin_content_encoding), + "should decode the origin's actual content encoding" + ); + assert_eq!(params.output_compression, Compression::None); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + assert_eq!( + response + .headers() + .get(HEADER_X_COMPRESS_HINT) + .and_then(|value| value.to_str().ok()), + Some("on") + ); + + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer decoded SSAT output"); + let body = response + .into_body() + .into_bytes() + .expect("should read decoded SSAT output"); + let body = + String::from_utf8(body.to_vec()).expect("should decode identity SSAT output"); + assert!( + body.contains("Compressed origin HTML"), + "should decode {origin_content_encoding} origin HTML" + ); + } + } + + #[tokio::test] + async fn unavailable_delivery_compression_keeps_mirrored_gzip_behavior() { + let settings = ssat_compression_test_settings(true); + let html = b"

Portable adapter

"; + let (publisher_response, stub, services, orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SMaxageFallback, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, + gzip_encode(html), + Some("gzip"), + ) + .await; + + let request_headers = stub.recorded_request_headers(); + assert!( + request_headers[0].iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "gzip, br" + }), + "non-Fastly adapters must retain supported client encodings" + ); + let PublisherResponse::Stream { + response, params, .. + } = &publisher_response + else { + panic!("processable SSAT HTML should use the stream route"); + }; + assert_eq!(params.input_compression, Compression::Gzip); + assert_eq!(params.output_compression, Compression::Gzip); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()), + Some("gzip") + ); + assert!(response.headers().get(HEADER_X_COMPRESS_HINT).is_none()); + assert_eq!( + response + .headers() + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(), + vec!["Origin", "Accept-Language"], + "non-Fastly adapters must not mutate Vary" + ); + + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer mirrored gzip output"); + let body = response + .into_body() + .into_bytes() + .expect("should read mirrored gzip output"); + let decoded = gzip_decode(&body); + assert!( + String::from_utf8(decoded) + .expect("should decode mirrored gzip") + .contains("Portable adapter"), + "non-Fastly output must remain valid gzip" + ); + } + + #[tokio::test] + async fn disabled_offload_keeps_default_mirrored_compression() { + let settings = ssat_compression_test_settings(false); + let html = b"

Default behavior

"; + let (publisher_response, stub, _services, _orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + gzip_encode(html), + Some("gzip"), + ) + .await; + + let request_headers = stub.recorded_request_headers(); + assert!(request_headers[0].iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "gzip, br" + })); + let PublisherResponse::Stream { + response, params, .. + } = publisher_response + else { + panic!("processable SSAT HTML should use the stream route"); + }; + assert_eq!(params.input_compression, Compression::Gzip); + assert_eq!(params.output_compression, Compression::Gzip); + assert!(response.headers().get(HEADER_X_COMPRESS_HINT).is_none()); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()), + Some("gzip") + ); + } + #[test] fn request_ec_uses_cookie_not_header() { let settings = create_test_settings(); @@ -3226,7 +3765,10 @@ mod tests { registry: None, }, req, - EdgeCacheHeader::SurrogateControl, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await .expect("should proxy publisher request") @@ -3436,7 +3978,10 @@ mod tests { registry: None, }, req, - EdgeCacheHeader::SurrogateControl, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::Unavailable, + }, ) .await .expect("should proxy publisher request"); @@ -4485,7 +5030,8 @@ mod tests { let body = EdgeBody::from(compressed); let params = OwnedProcessResponseParams { - content_encoding: "gzip".to_string(), + input_compression: Compression::Gzip, + output_compression: Compression::Gzip, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4532,7 +5078,8 @@ mod tests { IntegrationRegistry::new(&settings).expect("should create integration registry"); let params = OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4571,7 +5118,8 @@ mod tests { r#""#; let state = Arc::new(Mutex::new(Some(bids_script.to_string()))); let params = OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4623,7 +5171,8 @@ mod tests { // Claim gzip encoding but feed non-gzip bytes. The GzDecoder will // error as soon as it tries to read the gzip header. let params = OwnedProcessResponseParams { - content_encoding: "gzip".to_string(), + input_compression: Compression::Gzip, + output_compression: Compression::Gzip, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4730,7 +5279,8 @@ mod tests { let body = EdgeBody::from(html.to_vec()); let params = OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), @@ -4786,7 +5336,8 @@ mod tests { // Small, single-fragment RSC script — placeholder path (not fallback). let html = br#""#; let params = OwnedProcessResponseParams { - content_encoding: String::new(), + input_compression: Compression::None, + output_compression: Compression::None, origin_host: "origin.example.com".to_string(), origin_url: "https://origin.example.com".to_string(), request_host: "proxy.example.com".to_string(), diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 47348225..2cafc8d6 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -46,6 +46,11 @@ pub struct Publisher { /// Keep this secret stable to allow existing links to decode. #[validate(custom(function = validate_redacted_not_empty))] pub proxy_secret: Redacted, + /// Enables Fastly dynamic delivery compression for eligible server-side ad + /// stack HTML responses. Defaults to `false` and has no effect on runtimes + /// that do not explicitly declare support for Fastly dynamic delivery. + #[serde(default)] + pub ssat_compression_offload_enabled: bool, /// Maximum number of bytes buffered when a publisher origin response is /// post-processed in full (HTML rewriting/injection) instead of streamed. /// This caps the *decoded, post-rewrite* output buffer and applies to any @@ -88,6 +93,7 @@ impl Default for Publisher { origin_url: String::default(), origin_host_header_override: None, proxy_secret: Redacted::default(), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: default_max_buffered_body_bytes(), } } @@ -130,6 +136,7 @@ impl Publisher { /// origin_url: "https://origin.example.com:8080".to_string(), /// origin_host_header_override: None, /// proxy_secret: Redacted::new("proxy-secret".to_string()), + /// ssat_compression_offload_enabled: false, /// max_buffered_body_bytes: 16 * 1024 * 1024, /// }; /// assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -4317,6 +4324,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "https://origin.example.com:8080".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "origin.example.com:8080"); @@ -4328,6 +4336,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "https://origin.example.com".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "origin.example.com"); @@ -4339,6 +4348,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "http://localhost:9090".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -4350,6 +4360,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "localhost:9090".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "localhost:9090"); @@ -4361,6 +4372,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "http://192.168.1.1:8080".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "192.168.1.1:8080"); @@ -4372,6 +4384,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "http://[::1]:8080".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; assert_eq!(publisher.origin_host(), "[::1]:8080"); @@ -4385,6 +4398,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "https://origin.example.com:8443".to_string(), origin_host_header_override: None, proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; @@ -4399,6 +4413,7 @@ origin_host_header_overide = "www.example.com""#, origin_url: "https://origin.example.com".to_string(), origin_host_header_override: Some("www.example.com".to_string()), proxy_secret: Redacted::new("test-secret".to_string()), + ssat_compression_offload_enabled: false, max_buffered_body_bytes: 16 * 1024 * 1024, }; @@ -4406,7 +4421,7 @@ origin_host_header_overide = "www.example.com""#, } #[test] - fn publisher_default_max_buffered_body_bytes_matches_config_default() { + fn publisher_defaults_match_config_defaults() { // The manual `Default` impl must agree with the serde default applied // when the key is omitted from TOML, so programmatic `Publisher::default()` // does not silently produce a zero-byte buffer cap. @@ -4415,6 +4430,10 @@ origin_host_header_overide = "www.example.com""#, super::default_max_buffered_body_bytes(), "Publisher::default() must use the same buffer cap as the TOML default" ); + assert!( + !Publisher::default().ssat_compression_offload_enabled, + "Publisher::default() must keep SSAT compression offload disabled" + ); let from_toml = Settings::from_toml( r#" @@ -4439,6 +4458,26 @@ origin_host_header_overide = "www.example.com""#, Publisher::default().max_buffered_body_bytes, "TOML default and Publisher::default() must stay aligned" ); + assert!( + !from_toml.publisher.ssat_compression_offload_enabled, + "omitted TOML key must keep SSAT compression offload disabled" + ); + } + + #[test] + fn publisher_ssat_compression_offload_parses_from_toml() { + let toml = crate_test_settings_str().replace( + "proxy_secret = \"unit-test-proxy-secret\"", + "proxy_secret = \"unit-test-proxy-secret\"\n ssat_compression_offload_enabled = true", + ); + + let settings = Settings::from_toml(&toml) + .expect("should parse publisher SSAT compression offload setting"); + + assert!( + settings.publisher.ssat_compression_offload_enabled, + "explicit TOML setting should enable SSAT compression offload" + ); } #[test] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 93876692..ea912f0f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -162,14 +162,15 @@ Core publisher settings for domain, origin, and proxy configuration. ### `[publisher]` -| Field | Type | Required | Description | -| ----------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- | -| `domain` | String | Yes | Publisher's apex domain name | -| `cookie_domain` | String | Yes | Domain for non-EC cookies (typically with leading dot) | -| `origin_url` | String | Yes | Full URL of publisher origin server | -| `origin_host_header_override` | String | No | Outbound Host header to send while connecting to `origin_url` | -| `proxy_secret` | String | Yes | Secret key for encrypting/signing proxy URLs | -| `max_buffered_body_bytes` | Integer | No | Max bytes buffered when a publisher response is post-processed in full (default 16 MiB) | +| Field | Type | Required | Description | +| ---------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- | +| `domain` | String | Yes | Publisher's apex domain name | +| `cookie_domain` | String | Yes | Domain for non-EC cookies (typically with leading dot) | +| `origin_url` | String | Yes | Full URL of publisher origin server | +| `origin_host_header_override` | String | No | Outbound Host header to send while connecting to `origin_url` | +| `proxy_secret` | String | Yes | Secret key for encrypting/signing proxy URLs | +| `ssat_compression_offload_enabled` | Boolean | No | Fastly-only dynamic delivery compression for eligible SSAT HTML (default `false`) | +| `max_buffered_body_bytes` | Integer | No | Max bytes buffered when a publisher response is post-processed in full (default 16 MiB) | > **Note:** EC cookies (`ts-ec`) derive their domain automatically as `.{domain}` and > do not use `cookie_domain`. The `cookie_domain` field is used by other cookie helpers. @@ -184,6 +185,8 @@ origin_url = "https://origin.publisher.com" # Optional: connect to origin_url but send this outbound Host header. # origin_host_header_override = "www.publisher.com" proxy_secret = "change-me-to-secure-random-value" +# Fastly only; disabled by default. +ssat_compression_offload_enabled = false ``` **Environment Override**: @@ -194,6 +197,7 @@ TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=.publisher.com TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com TRUSTED_SERVER__PUBLISHER__ORIGIN_HOST_HEADER_OVERRIDE=www.publisher.com TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=your-secret-here +TRUSTED_SERVER__PUBLISHER__SSAT_COMPRESSION_OFFLOAD_ENABLED=true TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 ``` @@ -299,6 +303,35 @@ openssl rand -base64 32 Changing `proxy_secret` invalidates all existing signed URLs. Plan rotations carefully and use graceful transition periods. ::: +#### `ssat_compression_offload_enabled` + +**Purpose**: Offload delivery compression for eligible server-side ad stack +(SSAT) HTML responses to Fastly dynamic compression. + +When enabled on the Fastly adapter, Trusted Server requests identity encoding +from the publisher origin, decodes a supported compressed response if the origin +ignores that request, emits identity HTML, and sends `X-Compress-Hint: on` for +Fastly delivery compression. `Vary: Accept-Encoding` is retained or added as +needed. The setting applies only when the existing SSAT request gates pass and +the response is processable HTML. + +The setting defaults to `false`. Axum, Cloudflare, and Spin explicitly declare +the capability unavailable, so enabling it there does not change request or +response compression behavior. + +::: warning Origin response size +Identity responses use more origin-to-edge bandwidth and count against Fastly's +current 10 MiB raw origin-response limit before Trusted Server processing. Keep +this option disabled for publishers whose representative identity HTML can +approach that limit. +::: + +**Environment Override**: + +```bash +TRUSTED_SERVER__PUBLISHER__SSAT_COMPRESSION_OFFLOAD_ENABLED=true +``` + #### `max_buffered_body_bytes` **Purpose**: Upper bound on the in-memory buffer used when a publisher origin @@ -998,23 +1031,23 @@ Rules are evaluated in file order; the first enabled matching rule wins. Disabled rules are ignored, which lets you keep framework presets documented in config without enabling them for every publisher. -| Field | Type | Required | Description | -| ---------------------------------- | ------------- | -------- | ----------------------------------------------------------------- | -| `id` | String | Yes | Unique operator-facing rule identifier | -| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | -| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | -| `path_prefix` | String | Matcher | Request path prefix | -| `path_glob` | String | Matcher | Single glob matched against the request path | -| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | -| `path_regex` | String | Matcher | Regex matched against the request path | -| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | -| `requires_hash_in_filename` | Boolean | No | Require an 8+ hex token in the final path segment before matching | -| `visibility` | String | No | `public` or `private` (default `public`) | -| `browser_ttl_seconds` | Integer | No | Browser `max-age` | -| `edge_ttl_seconds` | Integer | No | Edge/shared-cache TTL | -| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | -| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | -| `immutable` | Boolean | No | Add `immutable` when browser TTL is positive | +| Field | Type | Required | Description | +| -------------------------------- | ------------- | -------- | ----------------------------------------------------------------- | +| `id` | String | Yes | Unique operator-facing rule identifier | +| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | +| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | +| `path_prefix` | String | Matcher | Request path prefix | +| `path_glob` | String | Matcher | Single glob matched against the request path | +| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | +| `path_regex` | String | Matcher | Regex matched against the request path | +| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | +| `requires_hash_in_filename` | Boolean | No | Require an 8+ hex token in the final path segment before matching | +| `visibility` | String | No | `public` or `private` (default `public`) | +| `browser_ttl_seconds` | Integer | No | Browser `max-age` | +| `edge_ttl_seconds` | Integer | No | Edge/shared-cache TTL | +| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | +| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | +| `immutable` | Boolean | No | Add `immutable` when browser TTL is positive | Exactly one matcher must be configured per rule. `path_glob` and `path_globs` are mutually exclusive. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index fa967ef9..0830a217 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -10,6 +10,9 @@ origin_url = "https://origin.example.com" # Optional: override outbound Host header while connecting to origin_url. # origin_host_header_override = "www.example.com" proxy_secret = "change-me-proxy-secret" +# Fastly only: request identity HTML from the origin and let Fastly dynamically +# compress eligible server-side ad stack responses at delivery. +ssat_compression_offload_enabled = false [ec] passphrase = "trusted-server-placeholder-secret" From 68c67c9460b1a303c4f07654fccdbb82eb75c730 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 9 Jul 2026 16:25:44 -0500 Subject: [PATCH 2/2] Fix SSAT compression offload rollout safety --- crates/trusted-server-core/src/config.rs | 32 ++++++ crates/trusted-server-core/src/publisher.rs | 106 ++++++++++++++++---- crates/trusted-server-core/src/settings.rs | 6 +- docs/guide/configuration.md | 32 ++++-- trusted-server.example.toml | 6 +- 5 files changed, 149 insertions(+), 33 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7bbecd74..1a4d5dfa 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -238,6 +238,38 @@ mod tests { ); } + #[test] + fn wrapper_omits_disabled_ssat_compression_offload_for_legacy_compatibility() { + let mut settings = valid_settings(); + settings.publisher.ssat_compression_offload_enabled = false; + let app_config = + TrustedServerAppConfig::new(settings).expect("should build disabled app config"); + let value = + serde_json::to_value(&app_config).expect("should serialize disabled app config"); + let publisher = value + .get("publisher") + .and_then(serde_json::Value::as_object) + .expect("should serialize publisher settings"); + + assert!( + !publisher.contains_key("ssat_compression_offload_enabled"), + "disabled setting must be omitted so older runtimes can load the config" + ); + + let mut settings = valid_settings(); + settings.publisher.ssat_compression_offload_enabled = true; + let app_config = + TrustedServerAppConfig::new(settings).expect("should build enabled app config"); + let value = serde_json::to_value(&app_config).expect("should serialize enabled app config"); + assert_eq!( + value + .get("publisher") + .and_then(|publisher| publisher.get("ssat_compression_offload_enabled")), + Some(&serde_json::Value::Bool(true)), + "enabled setting must remain present in the published config" + ); + } + #[test] fn wrapper_deserializes_from_settings_shape() { let toml = crate_test_settings_str(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 669d5631..b23f7830 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2224,18 +2224,11 @@ pub async fn handle_publisher_request( } ); - if compression_offload_active { - // Ask the origin for identity so Trusted Server can emit uncompressed - // HTML for Fastly's dynamic delivery compression. The response path - // still decodes supported compression if the origin ignores this. - req.headers_mut().insert( - header::ACCEPT_ENCODING, - HeaderValue::from_static("identity"), - ); - } else { - // Only advertise encodings the rewrite pipeline can decode and re-encode. - restrict_accept_encoding(&mut req); - } + // Only advertise encodings the rewrite pipeline can decode. Compression + // offload changes the processed HTML representation sent to Fastly, not + // origin negotiation, so non-HTML responses retain their negotiated + // encoding. + restrict_accept_encoding(&mut req); // Strip the internal `fastly-ssl` scheme signal before forwarding to the // origin. On the EdgeZero path the entry point re-injects this header from // trusted Fastly TLS metadata so in-process scheme detection works; the @@ -3328,6 +3321,8 @@ mod tests { settings: &Settings, adapter_options: PublisherAdapterOptions, origin_body: Vec, + origin_status: u16, + origin_content_type: &str, origin_content_encoding: Option<&str>, ) -> ( PublisherResponse, @@ -3339,7 +3334,7 @@ mod tests { let mut origin_headers = vec![ ( header::CONTENT_TYPE.as_str().to_string(), - "text/html; charset=utf-8".to_string(), + origin_content_type.to_string(), ), ( header::CONTENT_LENGTH.as_str().to_string(), @@ -3357,7 +3352,7 @@ mod tests { origin_content_encoding.to_string(), )); } - stub.push_response_with_headers(200, origin_body, origin_headers); + stub.push_response_with_headers(origin_status, origin_body, origin_headers); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); @@ -3371,7 +3366,7 @@ mod tests { .method(Method::GET) .uri("https://publisher.example/article?edition=morning&view=full") .header(header::HOST, "publisher.example") - .header(header::ACCEPT_ENCODING, "gzip, br") + .header(header::ACCEPT_ENCODING, "gzip, br, identity;q=0") .header("sec-fetch-dest", "document") .body(EdgeBody::empty()) .expect("should build SSAT compression request"); @@ -3445,7 +3440,7 @@ mod tests { } #[tokio::test] - async fn fastly_ssat_compression_offload_requests_and_emits_identity() { + async fn fastly_ssat_compression_offload_preserves_origin_negotiation_and_emits_identity() { let settings = ssat_compression_test_settings(true); let html = b"

Origin HTML

"; let (publisher_response, stub, services, orchestrator) = run_ssat_compression_request( @@ -3455,6 +3450,8 @@ mod tests { delivery_compression: DeliveryCompressionCapability::FastlyDynamic, }, html.to_vec(), + 200, + "text/html; charset=utf-8", None, ) .await; @@ -3467,9 +3464,9 @@ mod tests { let request_headers = stub.recorded_request_headers(); assert!( request_headers[0].iter().any(|(name, value)| { - name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "identity" + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "gzip, br" }), - "Fastly SSAT offload must request identity from the origin" + "Fastly SSAT offload must retain supported origin negotiation" ); let PublisherResponse::Stream { @@ -3534,6 +3531,73 @@ mod tests { assert!(body.contains("tsjs"), "should still process SSAT HTML"); } + #[tokio::test] + async fn fastly_ssat_compression_offload_preserves_non_html_error_compression() { + let settings = ssat_compression_test_settings(true); + let json = br#"{"message":"Negotiated JSON"}"#; + let (publisher_response, stub, services, orchestrator) = run_ssat_compression_request( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + gzip_encode(json), + 502, + "application/json", + Some("gzip"), + ) + .await; + + let request_headers = stub.recorded_request_headers(); + assert!( + request_headers[0].iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header::ACCEPT_ENCODING.as_str()) && value == "gzip, br" + }), + "offload must preserve supported origin negotiation for non-HTML responses" + ); + + let PublisherResponse::Stream { + response, params, .. + } = &publisher_response + else { + panic!("processable JSON should use the stream route"); + }; + assert_eq!(response.status(), http::StatusCode::BAD_GATEWAY); + assert_eq!(params.input_compression, Compression::Gzip); + assert_eq!(params.output_compression, Compression::Gzip); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()), + Some("gzip") + ); + assert!(response.headers().get(HEADER_X_COMPRESS_HINT).is_none()); + + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let response = buffer_publisher_response_async( + publisher_response, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should buffer mirrored non-HTML output"); + let body = response + .into_body() + .into_bytes() + .expect("should read mirrored non-HTML output"); + let decoded = gzip_decode(&body); + assert!( + String::from_utf8(decoded) + .expect("should decode mirrored non-HTML gzip") + .contains("Negotiated JSON"), + "non-HTML output must remain valid gzip" + ); + } + #[tokio::test] async fn fastly_ssat_compression_offload_decodes_supported_origin_encodings() { let settings = ssat_compression_test_settings(true); @@ -3553,6 +3617,8 @@ mod tests { delivery_compression: DeliveryCompressionCapability::FastlyDynamic, }, origin_body, + 200, + "text/html; charset=utf-8", Some(origin_content_encoding), ) .await; @@ -3612,6 +3678,8 @@ mod tests { delivery_compression: DeliveryCompressionCapability::Unavailable, }, gzip_encode(html), + 200, + "text/html; charset=utf-8", Some("gzip"), ) .await; @@ -3685,6 +3753,8 @@ mod tests { delivery_compression: DeliveryCompressionCapability::FastlyDynamic, }, gzip_encode(html), + 200, + "text/html; charset=utf-8", Some("gzip"), ) .await; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2cafc8d6..e4ad1506 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -27,6 +27,10 @@ pub const ENVIRONMENT_VARIABLE_PREFIX: &str = "TRUSTED_SERVER"; #[cfg(test)] pub const ENVIRONMENT_VARIABLE_SEPARATOR: &str = "__"; +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Publisher { @@ -49,7 +53,7 @@ pub struct Publisher { /// Enables Fastly dynamic delivery compression for eligible server-side ad /// stack HTML responses. Defaults to `false` and has no effect on runtimes /// that do not explicitly declare support for Fastly dynamic delivery. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub ssat_compression_offload_enabled: bool, /// Maximum number of bytes buffered when a publisher origin response is /// post-processed in full (HTML rewriting/injection) instead of streamed. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ea912f0f..37ac8e0f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -185,8 +185,8 @@ origin_url = "https://origin.publisher.com" # Optional: connect to origin_url but send this outbound Host header. # origin_host_header_override = "www.publisher.com" proxy_secret = "change-me-to-secure-random-value" -# Fastly only; disabled by default. -ssat_compression_offload_enabled = false +# Fastly only; disabled by default. Uncomment after deploying a compatible binary. +# ssat_compression_offload_enabled = true ``` **Environment Override**: @@ -308,22 +308,32 @@ Changing `proxy_secret` invalidates all existing signed URLs. Plan rotations car **Purpose**: Offload delivery compression for eligible server-side ad stack (SSAT) HTML responses to Fastly dynamic compression. -When enabled on the Fastly adapter, Trusted Server requests identity encoding -from the publisher origin, decodes a supported compressed response if the origin -ignores that request, emits identity HTML, and sends `X-Compress-Hint: on` for +When enabled on the Fastly adapter, Trusted Server continues to negotiate only +supported encodings with the publisher origin. For processable HTML, it decodes +the origin response, emits identity HTML, and sends `X-Compress-Hint: on` for Fastly delivery compression. `Vary: Accept-Encoding` is retained or added as needed. The setting applies only when the existing SSAT request gates pass and -the response is processable HTML. +the response is processable HTML; non-HTML responses retain their negotiated +origin encoding. The setting defaults to `false`. Axum, Cloudflare, and Spin explicitly declare the capability unavailable, so enabling it there does not change request or response compression behavior. -::: warning Origin response size -Identity responses use more origin-to-edge bandwidth and count against Fastly's -current 10 MiB raw origin-response limit before Trusted Server processing. Keep -this option disabled for publishers whose representative identity HTML can -approach that limit. +::: warning Fastly delivery billing +Fastly bills dynamically compressed responses based on their uncompressed size +before compression when the service is subject to metered delivery charges. +This option can therefore increase billable delivery bytes even though browsers +receive compressed content. Include uncompressed response size and projected +delivery cost in rollout measurements. See Fastly's +[compression guide](https://www.fastly.com/documentation/guides/concepts/compression/). +::: + +::: warning Deployment ordering +Deploy a Trusted Server binary that supports this setting before enabling it. +Before rolling back to an older binary, set the option to `false` and publish +the configuration with the current CLI so the disabled field is omitted, then +roll back the binary. ::: **Environment Override**: diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0830a217..cb2c0d94 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -10,9 +10,9 @@ origin_url = "https://origin.example.com" # Optional: override outbound Host header while connecting to origin_url. # origin_host_header_override = "www.example.com" proxy_secret = "change-me-proxy-secret" -# Fastly only: request identity HTML from the origin and let Fastly dynamically -# compress eligible server-side ad stack responses at delivery. -ssat_compression_offload_enabled = false +# Fastly only: emit processed server-side ad stack HTML as identity and let +# Fastly dynamically compress it at delivery. Defaults to false. +# ssat_compression_offload_enabled = true [ec] passphrase = "trusted-server-placeholder-secret"