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/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 1262676d..b23f7830 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,7 +2224,10 @@ pub async fn handle_publisher_request( } ); - // Only advertise encodings the rewrite pipeline can decode and re-encode. + // 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 @@ -2303,14 +2400,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 +3096,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 +3126,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 +3156,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 +3177,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 +3294,493 @@ 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_status: u16, + origin_content_type: &str, + 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(), + origin_content_type.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(origin_status, 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, identity;q=0") + .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_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( + &settings, + PublisherAdapterOptions { + edge_cache_header: EdgeCacheHeader::SurrogateControl, + delivery_compression: DeliveryCompressionCapability::FastlyDynamic, + }, + html.to_vec(), + 200, + "text/html; charset=utf-8", + 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 == "gzip, br" + }), + "Fastly SSAT offload must retain supported origin negotiation" + ); + + 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_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); + 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, + 200, + "text/html; charset=utf-8", + 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), + 200, + "text/html; charset=utf-8", + 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), + 200, + "text/html; charset=utf-8", + 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 +3835,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 +4048,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 +5100,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 +5148,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 +5188,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 +5241,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 +5349,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 +5406,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..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 { @@ -46,6 +50,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, 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. /// This caps the *decoded, post-rewrite* output buffer and applies to any @@ -88,6 +97,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 +140,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 +4328,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 +4340,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 +4352,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 +4364,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 +4376,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 +4388,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 +4402,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 +4417,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 +4425,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 +4434,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 +4462,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..37ac8e0f 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. Uncomment after deploying a compatible binary. +# ssat_compression_offload_enabled = true ``` **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,45 @@ 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 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; 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 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**: + +```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 +1041,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..cb2c0d94 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: 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"