diff --git a/Cargo.lock b/Cargo.lock index 70316712ac..74afc2b7dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "base16ct 1.0.0", "serde", @@ -1543,7 +1543,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "anyhow", "async-trait", @@ -1571,7 +1571,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "anyhow", "async-trait", @@ -1594,7 +1594,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "anyhow", "async-stream", @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "anyhow", "async-trait", @@ -1651,7 +1651,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "chrono", "clap", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "anyhow", "async-compression", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "log", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 139cf4f610..bc866b335a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,12 +56,12 @@ cssparser = "0.36" derive_more = { version = "2.0", features = ["display", "error"] } directories = "5" ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11" } -edgezero-core = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec", default-features = false } env_logger = "0.11" error-stack = "0.6" esi = "0.7.2" diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 3cc13fdfa8..159c203203 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -772,7 +772,7 @@ fn send_response_to_client(mut response: HttpResponse, counters: Option<&Sandbox } } -/// Apply every late response mutation, then restore privacy invariants before headers commit. +/// Apply late mutations, then restore privacy and encoding before headers commit. fn apply_terminal_response_effects( response: &mut HttpResponse, request_filter_effects: Option<&RequestFilterEffects>, @@ -800,6 +800,7 @@ fn apply_terminal_response_effects( // `apply_finalize_headers` and normalized asset policy reapplication ran. crate::middleware::enforce_set_cookie_cache_privacy(response); crate::middleware::enforce_uncacheable_cache_privacy(response); + edgezero_adapter_fastly::delivery_compression::finalize_response(response); } const FALLBACK_UNAVAILABLE: &str = "unavailable"; @@ -903,6 +904,9 @@ mod tests { use super::*; use base64::Engine as _; use edgezero_core::body::Body as EdgeBody; + use edgezero_core::delivery_compression::{ + DeliveryCompressionIntent, DeliveryCompressionSupport, + }; use edgezero_core::http::response_builder; use edgezero_core::http::{HeaderValue, Method, StatusCode}; use edgezero_core::ingress::{ @@ -1357,6 +1361,135 @@ mod tests { assert!(response.headers().get("etag").is_none()); } + #[test] + fn late_filter_effects_cannot_change_offloaded_delivery_encoding() { + futures::executor::block_on(async { + let identity = b"Example article"; + for streaming in [false, true] { + let body = if streaming { + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + identity, + )])) + } else { + EdgeBody::from(identity.to_vec()) + }; + let mut response = response_builder() + .header("content-type", "text/html") + .header("content-length", identity.len()) + .header("cache-control", "private, no-store") + .body(body) + .expect("should install final identity output"); + response.extensions_mut().insert(TerminalPrivateResponse); + DeliveryCompressionSupport::GzipBrotli + .mark_identity_response(&mut response) + .expect("should mark installed identity output"); + assert!( + response + .extensions() + .get::() + .is_some() + ); + assert!(response.headers().get("x-compress-hint").is_none()); + let effects = RequestFilterEffects { + request_headers: Vec::new(), + response_headers: vec![ + HeaderMutation::set("content-encoding", "gzip"), + HeaderMutation::set("content-length", "9999"), + HeaderMutation::set("x-compress-hint", "off"), + HeaderMutation::set("vary", "Origin"), + HeaderMutation::set("cache-control", "public, s-maxage=3600"), + HeaderMutation::set("surrogate-control", "max-age=3600"), + HeaderMutation::set("cdn-cache-control", "max-age=3600"), + ], + }; + + apply_terminal_response_effects(&mut response, Some(&effects)); + + assert!(response.headers().get("content-encoding").is_none()); + assert!(response.headers().get("content-length").is_none()); + assert_eq!(response.headers()["x-compress-hint"], "on"); + assert!( + response.headers().get_all("vary").iter().any(|value| { + value + .to_str() + .is_ok_and(|value| value.eq_ignore_ascii_case("accept-encoding")) + }), + "should restore delivery variation after late effects" + ); + assert_eq!(response.headers()["cache-control"], "no-store, private"); + assert!(response.headers().get("surrogate-control").is_none()); + assert!(response.headers().get("cdn-cache-control").is_none()); + assert_eq!( + response.body().is_stream(), + streaming, + "should preserve send form" + ); + let body = response + .into_body() + .into_bytes_bounded(1024) + .await + .expect("should collect guest identity output"); + assert_eq!( + body.as_ref(), + identity, + "should preserve exact identity bytes" + ); + } + }); + } + + #[test] + fn terminal_delivery_finalization_preserves_unmarked_gzip() { + futures::executor::block_on(async { + let gzip = base64::engine::general_purpose::STANDARD + .decode( + "H4sIAAAAAAAAA7PJKMnNsbNJyk+ptHPNS85PSU1RKM5MysnMS7fRB4va6IOVAAB6wM8tKQAAAA==", + ) + .expect("should decode actual gzip fixture"); + for streaming in [false, true] { + let body = if streaming { + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from( + gzip.clone(), + )])) + } else { + EdgeBody::from(gzip.clone()) + }; + let mut response = response_builder() + .header("content-type", "text/html") + .header("content-encoding", "gzip") + .header("content-length", gzip.len()) + .header("vary", "Accept-Encoding") + .body(body) + .expect("should build encoded sibling"); + let headers = response.headers().clone(); + + apply_terminal_response_effects(&mut response, None); + + assert_eq!( + response.headers(), + &headers, + "should leave encoded framing unchanged" + ); + assert!( + response + .extensions() + .get::() + .is_none() + ); + assert_eq!( + response + .into_body() + .into_bytes_bounded(1024) + .await + .expect("should read encoded sibling") + .as_ref(), + gzip.as_slice(), + "should never relabel or re-encode unmarked gzip" + ); + } + }); + } + #[test] fn late_filter_effects_cannot_make_a_page_bids_response_public() { let mut response = trusted_server_core::publisher::page_bids_preflight_denied(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a670d31416..9a1119dab2 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -32,6 +32,9 @@ use brotli::enc::writer::CompressorWriter; use chrono::{DateTime, Utc}; use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; +#[cfg(test)] +use edgezero_core::delivery_compression::DeliveryCompressionIntent; +use edgezero_core::delivery_compression::DeliveryCompressionSupport; use error_stack::{Report, ResultExt}; use flate2::read::ZlibDecoder; use flate2::write::{GzEncoder, ZlibEncoder}; @@ -501,9 +504,9 @@ fn set_response_compression(response: &mut Response, compression: Comp .iter() .any(|value| { value.to_str().is_ok_and(|value| { - value - .split(',') - .any(|name| name.trim().eq_ignore_ascii_case("accept-encoding")) + value.split(',').any(|name| { + name.trim() == "*" || name.trim().eq_ignore_ascii_case("accept-encoding") + }) }) }); if !varies_on_encoding { @@ -514,6 +517,39 @@ fn set_response_compression(response: &mut Response, compression: Comp response.headers_mut().remove(header::CONTENT_LENGTH); } +/// Pending final delivery, independent of origin decoding and template storage. +#[derive(Clone, Copy, Debug)] +pub(crate) enum OutputCompression { + Guest(Compression), + AdapterIdentity(DeliveryCompressionSupport), +} + +impl OutputCompression { + fn compression(self) -> Compression { + match self { + Self::Guest(compression) => compression, + Self::AdapterIdentity(_) => Compression::None, + } + } +} + +fn finalize_delivery_output( + response: &mut Response, + output: Option, +) -> Result<(), Report> { + if let Some(OutputCompression::AdapterIdentity(support)) = output { + // The final identity bytes or identity-producing stream are installed. + set_response_compression(response, Compression::None); + support + .mark_identity_response(response) + .change_context_lazy(|| TrustedServerError::Proxy { + message: "failed to mark identity delivery output".to_string(), + })?; + } + Ok(()) +} + +#[cfg(test)] fn response_compression(response: &Response) -> Compression { response .headers() @@ -831,11 +867,7 @@ async fn process_response_streaming_async( let input_compression = Compression::from_content_encoding(¶ms.content_encoding); // A template-cache response is always identity bytes. Decode during the transform instead of // recompressing and immediately decoding the entire buffered result afterwards. - let output_compression = if params.template_cache_key.is_some() { - Compression::None - } else { - input_compression - }; + let output_compression = params.transform_compression(); // This path has no seam controller, so it must never be reached with a // pending auction; `deferred_inline_seam_token` returns `None` for it. let (mut processor, inline_seam_token) = @@ -1762,6 +1794,8 @@ pub struct OwnedProcessResponseParams { /// Origin policy headers to store with the template and replay on a hit. pub(crate) policy_headers: Vec<(String, String)>, pub(crate) content_encoding: String, + /// Delivery override independent of the origin decoder. + pub(crate) output_compression: Option, pub(crate) origin_host: String, pub(crate) origin_url: String, pub(crate) request_host: String, @@ -1799,6 +1833,21 @@ pub struct OwnedProcessResponseParams { } impl OwnedProcessResponseParams { + fn transform_compression(&self) -> Compression { + if self.template_cache_key.is_some() { + Compression::None + } else { + self.delivery_compression() + } + } + + fn delivery_compression(&self) -> Compression { + self.output_compression.map_or_else( + || Compression::from_content_encoding(&self.content_encoding), + OutputCompression::compression, + ) + } + fn is_range_associated(&self) -> bool { self.range_associated || self.response_status == StatusCode::PARTIAL_CONTENT } @@ -1929,9 +1978,8 @@ pub async fn buffer_publisher_response_async( services, ) .await?; - // Authorized template cache transforms are emitted as identity by - // `process_response_streaming_async`; inline transforms retain the origin - // coding. This avoids recompressing and immediately decoding a full document. + // Authorized template transforms stay identity before storage. Other + // transforms use the independently selected delivery encoding. let bytes = output.into_inner(); // The origin readthrough cache holds raw origin bytes; the template cache // stores processed reader-neutral HTML. Caching the final per-user @@ -2033,7 +2081,7 @@ pub async fn buffer_publisher_response_async( set_assembly_response_state(&mut response, state); } let bytes = if was_authorized { - encode_complete_body(bytes, response_compression(&response))? + encode_complete_body(bytes, params.delivery_compression())? } else { bytes }; @@ -2042,6 +2090,7 @@ pub async fn buffer_publisher_response_async( http::HeaderValue::from(bytes.len() as u64), ); *response.body_mut() = EdgeBody::from(bytes); + finalize_delivery_output(&mut response, params.output_compression)?; Ok(response) } PublisherResponse::AssembleTemplate { @@ -2082,12 +2131,13 @@ pub async fn buffer_publisher_response_async( assembled.extend_from_slice(head); assembled.extend_from_slice(seam.as_bytes()); assembled.extend_from_slice(tail); - let assembled = encode_complete_body(assembled, response_compression(&response))?; + let assembled = encode_complete_body(assembled, params.delivery_compression())?; response.headers_mut().insert( http::header::CONTENT_LENGTH, http::HeaderValue::from(assembled.len() as u64), ); *response.body_mut() = EdgeBody::from(assembled); + finalize_delivery_output(&mut response, params.output_compression)?; Ok(response) } PublisherResponse::PassThrough { mut response, body } => { @@ -2236,6 +2286,7 @@ fn build_template_assembly_params( ad_bids_state: AdBidsState, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -2568,7 +2619,8 @@ pub async fn publisher_response_into_streaming_response( let services = services.clone(); let settings = Arc::clone(&settings); let orchestrator = Arc::clone(&orchestrator); - let compression = response_compression(&response); + let output_compression = params.output_compression; + let compression = params.delivery_compression(); // Arm the drop warning before constructing the lazy body. A reader can // disconnect after receiving the cached article prefix but before the seam // is polled, just like on the ordinary streaming path. @@ -2638,6 +2690,7 @@ pub async fn publisher_response_into_streaming_response( } }; *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + finalize_delivery_output(&mut response, output_compression)?; Ok(response) } PublisherResponse::PassThrough { mut response, body } => { @@ -2687,6 +2740,7 @@ pub async fn publisher_response_into_streaming_response( } response.headers_mut().remove(header::CONTENT_LENGTH); let mut params = *params; + let output_compression = params.output_compression; if is_html_content_type(¶ms.content_type) && integration_registry.has_async_html_finish() { @@ -2731,6 +2785,7 @@ pub async fn publisher_response_into_streaming_response( } }; *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + finalize_delivery_output(&mut response, output_compression)?; return Ok(response); } @@ -2767,7 +2822,7 @@ pub async fn publisher_response_into_streaming_response( let compression = Compression::from_content_encoding(¶ms.content_encoding); let max_body_bytes = settings.publisher.max_buffered_body_bytes; let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); - let mut encoder = BodyStreamEncoder::new(compression); + let mut encoder = BodyStreamEncoder::new(params.transform_compression()); let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE) .with_max_bytes(max_body_bytes); @@ -2905,6 +2960,7 @@ pub async fn publisher_response_into_streaming_response( } }; *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + finalize_delivery_output(&mut response, output_compression)?; Ok(response) } } @@ -3120,12 +3176,7 @@ pub fn stream_publisher_body( shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.as_ref(), }; - let input_compression = Compression::from_content_encoding(¶ms.content_encoding); - let output_compression = if params.template_cache_key.is_some() { - Compression::None - } else { - input_compression - }; + let output_compression = params.transform_compression(); process_response_streaming(body, output, &borrowed, output_compression) } @@ -3374,11 +3425,7 @@ pub async fn stream_publisher_body_async( }; let input_compression = Compression::from_content_encoding(¶ms.content_encoding); - let output_compression = if params.template_cache_key.is_some() { - Compression::None - } else { - input_compression - }; + let output_compression = params.transform_compression(); stream_html_with_auction_hold( body, output, @@ -4900,6 +4947,7 @@ pub async fn handle_publisher_request( mut req: Request, edge_header: EdgeCacheHeader, ) -> Result> { + let delivery_support = DeliveryCompressionSupport::from_request(&req); auction .integration_registry .ensure_async_html_finish_allowed(settings)?; @@ -5150,6 +5198,12 @@ pub async fn handle_publisher_request( || datadome_suppression_requires_origin; let reader_compression = negotiate_reader_compression(req.headers()); let reader_supports_assembly = reader_compression.is_ok(); + let delivery_offload_eligible = delivery_support == DeliveryCompressionSupport::GzipBrotli + && should_run_ad_stack + && matches!( + reader_compression, + Ok(Compression::None | Compression::Gzip | Compression::Brotli) + ); // A failed negotiation bypasses template cache below, so this value is used only on an // admitted path. Keeping an identity fallback avoids making that relationship // a panic-prone invariant in the public request handler. @@ -5636,6 +5690,11 @@ pub async fn handle_publisher_request( price_granularity, ad_bids_state.clone(), ); + params.output_compression = Some(if delivery_offload_eligible { + OutputCompression::AdapterIdentity(delivery_support) + } else { + OutputCompression::Guest(reader_compression) + }); params.seam_ad_slots = seam_ad_slots.clone(); params.dispatched_auction = dispatched_auction.take(); params.auction_observation = auction_observation.take(); @@ -5972,6 +6031,12 @@ pub async fn handle_publisher_request( if template_cache_key.is_some() { set_response_compression(&mut response, reader_compression); } + let offload_delivery = delivery_offload_eligible + && route == ResponseRoute::Stream + && status.is_success() + && !response_is_range_associated(&response) + && response_carries_body(&request_method, status) + && is_html_content_type(&content_type); match route { ResponseRoute::PassThrough => { @@ -6079,9 +6144,16 @@ pub async fn handle_publisher_request( // present on the live path so no future caller has to remember to // supply it — the handlers themselves stay gated on authorization. csp_nonce_observed: Some(Arc::new(AtomicBool::new(false))), - template_cache_key, seam_ad_slots, policy_headers, + output_compression: if offload_delivery { + Some(OutputCompression::AdapterIdentity(delivery_support)) + } else if template_cache_key.is_some() { + Some(OutputCompression::Guest(reader_compression)) + } else { + None + }, + template_cache_key, content_encoding, origin_host, origin_url: settings.publisher.origin_url.clone(), @@ -9929,6 +10001,7 @@ mod tests { content_encoding: &str, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -11531,6 +11604,11 @@ mod tests { //! the cache without touching the origin. Everything else in this file tests a //! link; this tests that they connect. + mod delivery_compression_tests { + use super::*; + include!("publisher/delivery_compression_tests.rs"); + } + use super::*; use crate::platform::ClientInfo; use crate::platform::test_support::{ @@ -23048,6 +23126,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let body = EdgeBody::from(compressed); let params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23110,6 +23189,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .expect("should create integration registry"); let params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23161,6 +23241,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { ) .expect("should create integration registry"); let params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23290,6 +23371,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23357,6 +23439,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23427,6 +23510,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23497,6 +23581,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23567,6 +23652,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23619,6 +23705,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { fn non_html_stream_params(content_encoding: &str) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23839,6 +23926,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let services = noop_services(); let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23917,6 +24005,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let services = noop_services(); let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -23997,6 +24086,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let services = noop_services(); let mut params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -24071,6 +24161,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .body(EdgeBody::empty()) .expect("should build response"); let params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -26035,6 +26126,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { dispatched_auction: Option, ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -26751,6 +26843,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let ec_context = EcContext::new_for_test(None, crate::consent::types::ConsentContext::default()); OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -26949,6 +27042,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .map(bytes::Bytes::copy_from_slice) .collect(); let params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27033,6 +27127,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { r#""#; let state = AdBidsState::with_script(bids_script); let params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27100,6 +27195,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { // 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 { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27221,6 +27317,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { let body = EdgeBody::from(html.to_vec()); let params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, @@ -27291,6 +27388,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { // Small, single-fragment RSC script — placeholder path (not fallback). let html = br#""#; let params = OwnedProcessResponseParams { + output_compression: None, response_status: StatusCode::OK, range_associated: false, csp_nonce_observed: None, diff --git a/crates/trusted-server-core/src/publisher/delivery_compression_tests.rs b/crates/trusted-server-core/src/publisher/delivery_compression_tests.rs new file mode 100644 index 0000000000..fe97e2bf24 --- /dev/null +++ b/crates/trusted-server-core/src/publisher/delivery_compression_tests.rs @@ -0,0 +1,616 @@ +#[derive(Clone, Copy)] +enum Finish { + Inline, + Replace, + Unchanged, +} + +async fn run_delivery( + settings: &Arc, + services: &RuntimeServices, + mut request: Request, + finalizer: Finalizer, + capability: DeliveryCompressionSupport, + finish: Finish, + stream_origin: bool, +) -> Response { + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + orchestrator.register_provider(Arc::new(WinningBidProvider { + price: STUB_BID_CPM, + })); + let orchestrator = Arc::new(orchestrator); + let registry = match finish { + Finish::Inline => { + IntegrationRegistry::new(settings).expect("should create integration registry") + } + Finish::Replace => IntegrationRegistry::from_html_stream_processors(vec![Arc::new( + super::super::TerminalTestFactory, + )]), + Finish::Unchanged => IntegrationRegistry::from_html_stream_processors(vec![Arc::new( + super::super::CountingTerminalFactory(Arc::new(AtomicUsize::new(0))), + )]), + }; + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test(None, consent); + if capability != DeliveryCompressionSupport::Unavailable { + request.extensions_mut().insert(capability); + } + let method = request.method().clone(); + let mut publisher = handle_publisher_request( + settings, + services, + None, + &mut ec_context, + AuctionDispatch { + integration_registry: ®istry, + orchestrator: &orchestrator, + slots: &[article_slot()], + registry: None, + }, + request, + EdgeCacheHeader::SurrogateControl, + ) + .await + .expect("should proxy delivery request"); + let response = match &publisher { + PublisherResponse::Buffered(response) + | PublisherResponse::Stream { response, .. } + | PublisherResponse::AssembleTemplate { response, .. } + | PublisherResponse::PassThrough { response, .. } => response, + }; + assert!( + response + .extensions() + .get::() + .is_none(), + "should not mark unfinished publisher output" + ); + // Reuse the HTTP fixture's response headers, then exercise both live input + // forms. The fixture's streaming-response helper cannot queue headers. + if stream_origin && let PublisherResponse::Stream { body, .. } = &mut publisher { + let bytes = std::mem::replace(body, EdgeBody::empty()) + .into_bytes() + .expect("should materialize fixture body"); + let chunks = bytes + .chunks(11) + .map(bytes::Bytes::copy_from_slice) + .collect::>(); + *body = EdgeBody::stream(futures::stream::iter(chunks)); + } + match finalizer { + Finalizer::Streaming => publisher_response_into_streaming_response( + publisher, + &method, + Arc::clone(settings), + ®istry, + orchestrator, + services.clone(), + ) + .await + .expect("should finalize lazy delivery"), + Finalizer::Buffered => buffer_publisher_response_async( + publisher, + &method, + settings, + ®istry, + &orchestrator, + services, + ) + .await + .expect("should finalize buffered delivery"), + } +} + +fn reader(encoding: &str) -> Request { + let mut request = navigation_request(); + request.headers_mut().insert( + header::ACCEPT_ENCODING, + HeaderValue::from_str(encoding).expect("should build reader encoding"), + ); + request +} + +fn assert_offloaded(response: &Response) { + assert!( + response + .extensions() + .get::() + .is_some(), + "should attach identity delivery intent" + ); + assert!( + !response.headers().contains_key("x-compress-hint"), + "should leave Fastly hints to the adapter" + ); + assert!( + !response.headers().contains_key(header::CONTENT_ENCODING), + "should emit identity" + ); + assert!( + response + .headers() + .get_all(header::VARY) + .iter() + .any(|v| v.to_str().is_ok_and(|v| v + .split(',') + .any(|v| v.trim() == "*" || v.trim().eq_ignore_ascii_case("accept-encoding")))), + "should retain encoding variation" + ); + assert!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.contains("private")), + "should remain private" + ); +} + +const ARTICLE: &[u8] = b"Example article"; + +fn queue_encoded(stub: &StubHttpClient, compression: Compression) { + let encoding = match compression { + Compression::None => "identity", + Compression::Gzip => "gzip", + Compression::Deflate => "deflate", + Compression::Brotli => "br", + }; + let origin = encode_complete_body(ARTICLE.to_vec(), compression).expect("should encode origin"); + let length = origin.len().to_string(); + stub.push_response_with_headers( + 200, + origin, + vec![ + ("content-type", "text/html"), + ("content-encoding", encoding), + ("content-length", &length), + ("cache-control", "public, max-age=300"), + ], + ); +} + +#[tokio::test] +async fn delivery_offload_decodes_origin_without_guest_recompression() { + for finalizer in [Finalizer::Streaming, Finalizer::Buffered] { + for stream_origin in [false, true] { + for finish in [Finish::Inline, Finish::Replace, Finish::Unchanged] { + for bidding in [false, true] { + let settings = + Arc::new(crate::publisher::allow_eof_test_settings(if bidding { + settings_with_bidder("inline") + } else { + settings_with_mode("inline") + })); + let mut expected = None; + for compression in [ + Compression::None, + Compression::Gzip, + Compression::Deflate, + Compression::Brotli, + ] { + let stub = Arc::new(StubHttpClient::new()); + let services = + services(Arc::clone(&stub), Arc::new(MemoryTemplateCache::default())); + if bidding { + queue_bid_response(&stub); + } + queue_encoded(&stub, compression); + let response = run_delivery( + &settings, + &services, + reader("br, gzip, deflate"), + finalizer, + DeliveryCompressionSupport::GzipBrotli, + finish, + stream_origin, + ) + .await; + assert_offloaded(&response); + assert_eq!( + response.body().is_stream(), + matches!(finalizer, Finalizer::Streaming), + "should preserve finalizer laziness" + ); + let length = response.headers().get(header::CONTENT_LENGTH).map(|v| { + v.to_str() + .expect("should read length") + .parse::() + .expect("should parse length") + }); + let body = body_of(response).await; + if let Some(length) = length { + assert_eq!(length, body.len(), "should recompute identity length"); + } + let html = std::str::from_utf8(&body).expect("should emit identity HTML"); + assert!(html.contains("Example article"), "should preserve article"); + assert_eq!( + html.ends_with(""), + matches!(finish, Finish::Replace), + "should retain EOF replacement or fallback" + ); + if bidding { + assert!(html.contains("3.50"), "should retain auction tail"); + } + if let Some(expected) = &expected { + assert_eq!( + &body, expected, + "should produce identical HTML for every origin coding" + ); + } else { + expected = Some(body); + } + let headers = stub.recorded_request_headers(); + assert_eq!( + headers + .last() + .expect("should record origin") + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("accept-encoding")) + .map(|(_, value)| value.as_str()), + Some("gzip, deflate, br"), + "should preserve existing origin negotiation" + ); + } + } + } + } + } +} + +#[tokio::test] +async fn delivery_offload_template_miss_hit_fallback_and_ineligible_reader() { + for finalizer in [Finalizer::Streaming, Finalizer::Buffered] { + for fail_assembly in [false, true] { + let settings = Arc::new(settings_with_mode("esi")); + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let assembler = Arc::new(RecordingTemplateAssembler::default()); + assembler.fail.store(fail_assembly, Ordering::Relaxed); + assembler.require_store_before_call(Arc::clone(&cache.insert_completed)); + let services = + services_with_assembler(Arc::clone(&stub), Arc::clone(&cache), assembler); + queue_encoded(&stub, Compression::Gzip); + for state in ["miss-stored", "hit"] { + let response = run_delivery( + &settings, + &services, + reader("br, gzip"), + finalizer, + DeliveryCompressionSupport::GzipBrotli, + Finish::Inline, + false, + ) + .await; + assert_offloaded(&response); + assert_eq!( + response + .headers() + .get(HEADER_X_TS_TEMPLATE_CACHE) + .and_then(|v| v.to_str().ok()), + Some(state), + "should retain template state" + ); + let assembly = if state == "hit" { + "byte-seam" + } else if fail_assembly { + "byte-seam-fallback" + } else { + "esi-parser" + }; + assert_eq!( + response + .headers() + .get(HEADER_X_TS_ASSEMBLY) + .and_then(|v| v.to_str().ok()), + Some(assembly), + "should retain assembly and fallback path" + ); + let html = String::from_utf8(body_of(response).await) + .expect("should deliver assembled identity"); + assert!( + html.contains("Example article"), + "should retain template content" + ); + assert!( + !html.contains(AD_ASSEMBLY_SEAM), + "should assemble before delivery" + ); + let entries = cache + .entries + .lock() + .expect("should inspect stored template"); + let entry = entries.values().next().expect("should store template"); + assert_eq!( + entry.metadata.content_encoding, "identity", + "should store neutral identity" + ); + assert!( + std::str::from_utf8(&entry.body) + .expect("should store HTML") + .contains(AD_ASSEMBLY_SEAM), + "should store before assembly" + ); + } + let mut request = reader("gzip"); + request.headers_mut().insert( + "sec-purpose", + HeaderValue::from_static("prefetch;prerender"), + ); + let response = run_delivery( + &settings, + &services, + request, + finalizer, + DeliveryCompressionSupport::GzipBrotli, + Finish::Inline, + false, + ) + .await; + assert_eq!( + response + .headers() + .get(HEADER_X_TS_TEMPLATE_CACHE) + .and_then(|v| v.to_str().ok()), + Some("hit"), + "should reuse template for ineligible reader" + ); + assert!( + response + .extensions() + .get::() + .is_none(), + "should use this reader's eligibility" + ); + assert_eq!( + response_compression(&response), + Compression::Gzip, + "should preserve reader encoding" + ); + let html = gzip_decode(&body_of(response).await); + assert!( + std::str::from_utf8(&html) + .expect("should decode template") + .contains("Example article"), + "should assemble ineligible reader" + ); + assert_eq!( + stub.recorded_request_uris().len(), + 1, + "should fetch only on miss" + ); + } + } +} + +#[tokio::test] +async fn delivery_offload_negotiation_and_capability_are_independent() { + for capability in [ + DeliveryCompressionSupport::Unavailable, + DeliveryCompressionSupport::GzipBrotli, + ] { + for (encoding, supported) in [ + (None, true), + (Some(""), true), + (Some("identity"), true), + (Some("gzip, identity;q=0"), true), + (Some("br, identity;q=0"), true), + (Some("deflate, identity;q=0"), false), + (Some("*;q=0"), false), + (Some("zstd, identity;q=0"), false), + (Some("gzip;q=broken"), false), + ] { + let settings = Arc::new(settings_with_mode("inline")); + let stub = Arc::new(StubHttpClient::new()); + let services = services(Arc::clone(&stub), Arc::new(MemoryTemplateCache::default())); + queue_encoded(&stub, Compression::Deflate); + let request = encoding.map_or_else(navigation_request, reader); + let response = run_delivery( + &settings, + &services, + request, + Finalizer::Buffered, + capability, + Finish::Inline, + false, + ) + .await; + let offloaded = capability == DeliveryCompressionSupport::GzipBrotli && supported; + assert_eq!( + response + .extensions() + .get::() + .is_some(), + offloaded, + "should respect capability and negotiation {encoding:?}" + ); + assert_eq!( + response_compression(&response), + if offloaded { + Compression::None + } else { + Compression::Deflate + }, + "should preserve existing fallback encoding" + ); + let body = body_of(response).await; + let body = if offloaded { + body + } else { + let mut decoded = Vec::new(); + flate2::read::ZlibDecoder::new(&body[..]) + .read_to_end(&mut decoded) + .expect("should decode deflate fallback"); + decoded + }; + assert!( + std::str::from_utf8(&body) + .expect("should emit HTML") + .contains("Example article"), + "should retain body" + ); + } + } +} + +#[tokio::test] +async fn delivery_offload_excludes_unmodified_and_non_ssat_responses() { + for (status, content_type, encoding, eligible, method, range) in [ + (200, "text/html", "gzip", false, Method::GET, false), + (200, "text/css", "gzip", true, Method::GET, false), + (200, "text/html", "zstd", true, Method::GET, false), + (302, "text/html", "gzip", true, Method::GET, false), + (404, "text/html", "gzip", true, Method::GET, false), + (500, "text/html", "gzip", true, Method::GET, false), + (200, "text/html", "gzip", true, Method::HEAD, false), + (204, "text/html", "gzip", true, Method::GET, false), + (205, "text/html", "gzip", true, Method::GET, false), + (304, "text/html", "gzip", true, Method::GET, false), + (206, "text/html", "gzip", true, Method::GET, true), + (200, "text/html", "gzip", true, Method::GET, true), + ] { + let mut settings = settings_with_mode("inline"); + settings.auction.enabled = eligible; + let settings = Arc::new(settings); + let stub = Arc::new(StubHttpClient::new()); + let services = services(Arc::clone(&stub), Arc::new(MemoryTemplateCache::default())); + let bytes = if encoding == "zstd" { + ARTICLE.to_vec() + } else { + gzip_encode(ARTICLE) + }; + let mut headers = vec![ + ("content-type", content_type), + ("content-encoding", encoding), + ]; + if range { + headers.push(("content-range", "bytes 0-9/100")); + } + stub.push_response_with_headers(status, bytes, headers); + let mut request = reader("br, gzip"); + *request.method_mut() = method.clone(); + let response = run_delivery( + &settings, + &services, + request, + Finalizer::Streaming, + DeliveryCompressionSupport::GzipBrotli, + Finish::Inline, + false, + ) + .await; + assert!( + response + .extensions() + .get::() + .is_none(), + "should not offload {method} {status} {content_type} {encoding}" + ); + let carries_body = response_carries_body(&method, response.status()); + let compression = response_compression(&response); + let body = body_of(response).await; + assert_eq!( + !body.is_empty(), + carries_body, + "should preserve bodyless semantics" + ); + if carries_body && compression == Compression::Gzip { + assert!( + !gzip_decode(&body).is_empty(), + "should retain in-guest gzip" + ); + } + } +} + +#[test] +fn delivery_offload_emits_identity_prefix_before_origin_eof_and_auction() { + let mut params = super::super::html_stream_params( + "gzip", + Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 500, + )), + ); + params.output_compression = Some(OutputCompression::AdapterIdentity( + DeliveryCompressionSupport::GzipBrotli, + )); + let body = super::super::streaming_finalize_response( + params, + super::super::origin_chunk_then_pending(bytes::Bytes::from(gzip_encode(ARTICLE))), + ); + let first = super::super::first_lazy_body_chunk(body); + let html = std::str::from_utf8(&first).expect("should emit identity prefix"); + assert!( + html.contains("Example article"), + "should emit article before origin EOF" + ); + assert!( + !html.contains("var b=JSON.parse("), + "should not await auction before prefix" + ); +} + +#[tokio::test] +async fn delivery_offload_preserves_vary_and_stronger_privacy() { + for vary in [ + vec!["Origin", "Accept-Language"], + vec!["Origin, aCcEpT-EnCoDiNg", "Accept-Language"], + vec!["*", "Origin"], + ] { + let settings = Arc::new(settings_with_mode("inline")); + let stub = Arc::new(StubHttpClient::new()); + let services = services(Arc::clone(&stub), Arc::new(MemoryTemplateCache::default())); + let mut headers = vec![ + ("content-type", "text/html"), + ("cache-control", "private, no-store"), + ("content-length", "9999"), + ]; + for value in &vary { + headers.push(("vary", *value)); + } + stub.push_response_with_headers(200, ARTICLE.to_vec(), headers); + let response = run_delivery( + &settings, + &services, + reader("gzip"), + Finalizer::Streaming, + DeliveryCompressionSupport::GzipBrotli, + Finish::Inline, + false, + ) + .await; + assert_offloaded(&response); + let actual = response + .headers() + .get_all(header::VARY) + .iter() + .map(|v| v.to_str().expect("should read vary")) + .collect::>(); + assert_eq!( + &actual[..vary.len()], + &vary, + "should preserve all Vary fields" + ); + assert_eq!( + actual.len(), + vary.len() + usize::from(vary.len() == 2 && vary[0] == "Origin"), + "should not duplicate wildcard or existing variation" + ); + assert!( + response.headers()[header::CACHE_CONTROL] + .to_str() + .expect("should read privacy") + .contains("no-store"), + "should preserve stronger privacy" + ); + assert!( + std::str::from_utf8(&body_of(response).await) + .expect("should keep identity") + .contains("Example article"), + "should keep final body and headers aligned" + ); + } +} diff --git a/crates/trusted-server-core/src/publisher/html_finalization.rs b/crates/trusted-server-core/src/publisher/html_finalization.rs index 49ad33a5f8..8a831f1c86 100644 --- a/crates/trusted-server-core/src/publisher/html_finalization.rs +++ b/crates/trusted-server-core/src/publisher/html_finalization.rs @@ -137,7 +137,7 @@ impl HtmlEofDriver { Ok(Self { response_status: params.response_status, input, - encoder: BodyStreamEncoder::new(compression), + encoder: BodyStreamEncoder::new(params.transform_compression()), processor, replay: VecDeque::new(), hold, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 8413e77d09..26c4082725 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -538,6 +538,28 @@ a zero-byte cap fails every non-empty publisher response. TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 ``` +### Fastly SSAT delivery compression + +Fastly automatically offloads final compression for eligible, successful SSAT +HTML responses. Trusted Server keeps its existing origin encoding negotiation, +decodes supported origin encodings, and emits processed identity HTML with +`X-Compress-Hint: on`. Fastly negotiates gzip or Brotli delivery using the reader's +original `Accept-Encoding`. No publisher setting is required. Adapter capability +and final delivery headers come from EdgeZero. Trusted Server attaches the typed +delivery intent only after installing identity bytes or the final lazy identity +stream; its custom sender uses EdgeZero's non-collecting header finalizer. + +This applies to eligible inline responses and assembled template misses and +hits. Requests for which reader negotiation selects deflate or fails retain the +existing in-guest encoding path. Non-SSAT, non-HTML, unsupported-encoding, error, +redirect, and bodyless responses keep their existing behavior. Axum, Cloudflare, +and Spin also retain their current compression behavior. + +Offload does not change template storage, cache admission, consent gates, or +terminal private/no-store policies. It does not request identity from origin or +change body-size limits. Local tests verify guest output; actual Fastly delivery +encoding and performance require staging validation. + ## Trusted Client IP Configuration Use this optional section when a trusted CDN service forwards requests to the diff --git a/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md b/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md new file mode 100644 index 0000000000..be11659a4b --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md @@ -0,0 +1,132 @@ +# Consume EdgeZero delivery compression + +## Goal + +Keep Trusted Server's SSAT output-only compression behavior while replacing its +local platform mechanism with [EdgeZero PR #452](https://github.com/IABTechLab/edgezero/pull/452). +Both PRs remain draft for joint review. The +[SSAT design](../specs/2026-10-29-858-ssat-delivery-compression-offload-design.md) +owns application policy; EdgeZero owns capability, checked identity intent, and +Fastly's final delivery headers. + +## Preparation + +- [x] Publish EdgeZero's reviewed spec, implementation plan, implementation, and + passing native/Viceroy/workspace checks in draft PR #452 before consumer edits. +- [x] Preserve old branch as `backup/issue-858-before-edgezero-20261102`. +- [x] Rebase one feature-only tree delta onto main + `51730bdaf841e4d7bb96c2258e5e98169bdff9a7`. Do not replay inherited parser-stack + or merge commits. The clean rebase preserves current trace, ingress, and + absolute-deadline code. + +## Implementation + +1. Pin all six root workspace EdgeZero dependencies to + `157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec`. Regenerate only the necessary lock + entries and verify all eight EdgeZero packages have one exact git source. + Do not update Fastly SDK pins or unrelated registry versions. +2. Read support from the incoming EdgeZero request before encoder selection. + Remove `DeliveryCompression`, `PublisherAdapterOptions`, their per-adapter + literals, and the local generic response marker/header helper. Restore the + existing cache-header argument where it is the only remaining publisher option. +3. Carry one explicit local pending output plan through every finalizer. It must + distinguish preserved input coding, explicit guest output, and adapter identity + output without a second synchronized offload boolean. Keep origin decoding and + template-store identity transforms separate from final delivery encoding. +4. Make buffered, lazy, template-miss/hit/fallback, and ordered-EOF outputs use that + plan. Install identity bytes or the final identity-producing stream first, + normalize its representation metadata, then call EdgeZero's checked marking + method. Never put the final intent on raw origin or pending intermediate output. +5. In the real custom Fastly sender, call the shared EdgeZero finalizer after late + header effects and existing privacy guards, before response parts are consumed. + Preserve its streaming pump, post-send work, error attribution, and all current + trace/deadline safeguards. Do not switch to the collecting standard converter. +6. Adapt regressions to inject typed request support and assert no premature + response intent. Core tests assert identity output/intent; Fastly terminal tests + assert the hint and framing after the framework finalizer. Retain unavailable, + deflate/failed-reader, non-SSAT/non-HTML, unsupported-origin, bodyless/range/error, + privacy, prefix-before-auction, and codec-equivalence coverage. + +## Verification and publication + +- Run the focused delivery, publisher, ordered-EOF and Fastly terminal tests through + the pinned dependency, then neighboring current-main trace/deadline/ingress tests. +- Run current repository CI gates, including adapter tests/lints, parity, digest, + clock, formatting, JS, docs, script/pin/host checks and the required local EOF and + deadline runtime fixtures. Record unavailable tooling or baseline failures rather + than claiming those checks pass. +- Inspect the final diff and dependency graph independently. No application-local + Fastly delivery helper or branch capability/options wrapper should remain. + Pre-existing static-asset hint behavior is outside this change. +- Commit the stable consumer state, update the original branch with an explicit + lease for its verified old remote SHA, and update draft PR #884. Retain the backup. +- Cross-link both draft PRs and both issues, record the exact framework dependency, + and keep staging negotiation/performance criteria open. + +Replace the temporary framework revision pin with a merged EdgeZero release before +marking the consumer merge-ready. No production deployment or performance claim is +part of this plan. + +## Implementation and verification record + +The consumer now uses the six exact dependency pins and eight matching lockfile +sources. The lockfile changes no registry or SDK versions. The pending output plan +replaces the local mechanism, and all four adapter `app.rs` files match the main +baseline. Independent source review found no blockers and verified all ten runtime +input hashes. + +The following records the 2026-11-02 integration checks for consumer `db27e738` +and framework `63c7b63`, before the test-only review follow-up below. These checks +passed with Rust 1.98.1, Node 24.20.0 and Viceroy 0.21.0: + +- All 32 Rust, adapter, lint, parity, digest, clock, host and documentation gates. + Native core passed 3,345 tests with two ignored. The normal Fastly alias passed + 244 adapter tests and 3,338 core tests, plus the remaining shared-crate and + documentation tests. The reusable-sandbox alias also passed. +- Actual Fastly deadline cases, Cloudflare and Spin deadline runtime fixtures, + and actual Axum plus release-mode Fastly EOF controls, including auction and + prefix-before-completion behavior. +- JavaScript tests passed 1,838 tests with one skipped; JS formatting/build and + repository script and Node-pin checks passed. +- Documentation formatting and build passed. The final review-log update receives + a separate documentation check before publication. + +An isolated single-package Fastly test build failed to link +`fastly_http_req::pending_req_header_insert` under Viceroy. Its cause remains +unresolved; unchanged-main, dependency-pin-only and shared-helper-only probes did +not reproduce it. The required `cargo test-fastly` and `cargo test-fastly-reuse` +configurations, actual deadline fixture and release EOF controls all passed with +the default runner. Final verification uses no trap override, SDK stub, pin change +or runtime-source workaround. Earlier trap-mode diagnostics are not final proof. + +## Review follow-up, 2026-11-03 + +Review finding R1 replaces the framework Vary test's copied production predicate +with literal expected header fields. Framework commit `1c0f4750` changes +only that test; this consumer refreshes all six declarations and eight lock +sources without changing registry or SDK versions. The original review and +integration evidence above remains associated with its original revisions. + +During the 2026-11-03 review, local SDK contract execution and both consumer +Fastly alias configurations failed before tests on +`fastly_http_req::pending_req_header_insert`. Same-tree hosted CI logs verified +successful SDK, alias and release EOF execution in that hosted environment. +The current local discrepancy remains unresolved. This follow-up includes no +runtime workaround and makes no new local WASM-pass claim. + +## CI fixture follow-up, 2026-11-03 + +Framework commit `157f091e` fixes the reusable HTTP smoke probe's +abort ordering. Its controlled origin now waits for the client to observe body +bytes before truncating. Immediate aborts remain available for pre-commit faults, +and the strict status, exact partial-body and correlated-error checks are unchanged. +No shipping SDK logic or runtime configuration changed. + +A new ordering regression failed before the fix. The fixed fixture passed two +core and 27 harness tests, formatting, strict Clippy and the full Fastly smoke +suite under pinned Viceroy 0.21.0. All three custom variants observed status 200 +and `partial` followed by the matching guest error. This consumer refreshes the +same six declarations and eight lock sources to the published fix. + +No deployment ran. Real Fastly reader negotiation, progressive CDN delivery, +service-chain behavior and vCPU/transfer measurements remain staging work. diff --git a/docs/superpowers/specs/2026-10-29-858-ssat-delivery-compression-offload-design.md b/docs/superpowers/specs/2026-10-29-858-ssat-delivery-compression-offload-design.md new file mode 100644 index 0000000000..28e434daba --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-858-ssat-delivery-compression-offload-design.md @@ -0,0 +1,228 @@ +# Fastly SSAT delivery compression offload + +## Goal and scope + +Issue #858 and PR #884 will offload final SSAT HTML compression to Fastly on +current `main`. Preserve origin encoding negotiation and the existing rewrite, +auction, streaming, template-cache, and privacy behavior. Do not revive the +closed parser-safe streaming branch. + +The July experiment measured approximately 44.6 ms versus 9.0 ms processing and +47 ms versus 19 ms Fastly vCPU, with Brotli browser delivery. That experiment +also requested identity from origin. These are historical measurements, not +proof of the output-only design's current performance. + +## Decision + +Automatically use Fastly dynamic delivery compression for eligible SSAT HTML. +There is no publisher opt-in or new configuration. Keep the existing restricted +origin `Accept-Encoding` negotiation. Decode supported origin encodings, rewrite +as today, and hand identity output to Fastly instead of recompressing in WASM. + +Alternatives considered: + +- Keep identity-origin fetching from the old PR. Rejected for this change because + it changes origin bandwidth and representation selection independently of + delivery offload. +- Keep a disabled-by-default publisher flag. Rejected in favor of automatic + Fastly behavior, following the maintainer's request to remove the opt-in. +- Apply offload to every rewritten response. Rejected because this issue is + limited to SSAT HTML and must not change assets, RSC, or ordinary proxying. + +```mermaid +flowchart TD + A[Existing publisher origin negotiation or template lookup] --> B[Existing SSAT HTML eligibility] + B --> C{Fastly delivery capability?} + C -->|No| D[Existing delivery encoding] + C -->|Yes| E[Decode origin if needed and preserve HTML assembly] + E --> F[Install identity output and attach EdgeZero intent] + F --> G[EdgeZero Fastly finalizer restores delivery headers after late effects] + G --> H[Fastly negotiates delivery compression with reader] +``` + +## Adapter and processing boundaries + +Read `DeliveryCompressionSupport` from EdgeZero's typed request extension before +selecting the output codec. Fastly's request converter advertises `GzipBrotli`; +missing support defaults to unavailable on Axum, Cloudflare, and Spin. This is +independent of cache headers and origin-streaming support. Remove the branch-local +capability enum and publisher-options wrapper once no other option needs it. +No Fastly SDK dependency enters core. + +### Framework dependency and pending output + +The generic capability, checked identity-response intent, and platform header +finalizer live in [EdgeZero PR #452](https://github.com/IABTechLab/edgezero/pull/452). +Pin all six workspace EdgeZero dependencies to exact commit +`157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec` while the paired PRs are under review. +All eight resolved EdgeZero packages must use that one source. Move to a merged +EdgeZero release before Trusted Server becomes merge-ready; do not substitute a +mutable branch or local path. + +Trusted Server owns a pending output plan through processing, template assembly, +and ordered EOF completion. Preserve-input, explicit guest encoding, and adapter +identity output are distinct choices. Template delivery must use this plan rather +than infer its encoder from response headers. Input decoding remains independent. + +Only after installing completed identity bytes or the final lazy identity-producing +stream may a publisher finalizer normalize its identity metadata and call +`support.mark_identity_response`. Raw origin bodies and partially prepared +`PublisherResponse` values must not carry the final intent. The shared API rejects +unavailable support or non-identity encoding metadata without mutating the +response; propagate such errors through the existing application error boundary. +Keep all body consumption lazy where it is lazy today. + +Fastly's documented dynamic delivery codecs are gzip and Brotli, not deflate. +Reuse reader negotiation to retain in-guest delivery when negotiation fails or +selects deflate. This preserves deflate-only readers, including those refusing +identity, and avoids expanding the existing malformed/unsupported-reader contract. +Successful identity, gzip, and Brotli negotiation can use offload. Do not change +origin negotiation in order to make a request eligible. + +Select output compression independently from input compression. The selected +output must survive every affected finalizer, including lazy inline streaming, +buffered HTML processing, template-cache miss assembly, warm template delivery, +and fallback assembly. This includes the current ordered `HtmlEofDriver`, whose +encoder must use the selected output for ordinary, replacement, fallback, and +auction-tail bytes through both lazy and buffered entry points. Reuse existing +decoders, encoders, and negotiation helpers. Do not refactor parser or EOF +completion behavior. + +Apply offload only to supported, processable HTML using the existing server-side +ad-stack eligibility. Do not expand consent gates or enable auctions for requests +that currently skip them. Unsupported encodings bypass rewriting unchanged. +Non-HTML, non-SSAT, redirects, error responses, and bodyless responses retain +their existing contracts. The offload predicate must explicitly require a +successful, body-bearing, supported HTML response and preserve existing range +bypasses. The broader processable-response classifier is not this predicate; +do not stop existing rewriting of redirects or errors just to exclude offload. + +Template hits use this reader's ad-stack eligibility and negotiation, not the +request that populated the template. Ineligible readers can still assemble a +template with their current encoding and terminal privacy. An auction's presence, +outcome, or absence is not a substitute for the eligibility decision. + +For offloaded delivery: + +- Emit identity bytes from the guest, with no `Content-Encoding`. +- Normalize identity metadata after installing the final body, then attach + EdgeZero's opaque intent. A buffered finalizer may calculate a correct identity + length, but must not reuse the encoded origin length. +- The Fastly sender calls EdgeZero's `delivery_compression::finalize_response` + after late effects. It sets `X-Compress-Hint: on` and removes encoding/length + headers for the selected identity output. +- Preserve existing `Vary` fields and ensure `Accept-Encoding` is covered; + `Vary: *` already covers it. +- Let Fastly negotiate the final representation using the original reader + request. Do not overwrite the reader's encoding preferences. Verify readers + with absent encoding headers, identity-only preferences, and identity refusal + against the real platform before declaring delivery validated. Keep deflate + selection and failed negotiation on the existing in-guest path. + +Observe encoding, hint, framing, and privacy at the actual send boundary after +late effects. Carry a pending application plan until output installation, then +use the EdgeZero typed response intent, not an inbound or configurable header. +Remove the local `DynamicDeliveryCompression` marker and `enforce_delivery_compression` +helper. The existing custom Fastly sender must call the shared EdgeZero finalizer +for both streamed and buffered sends alongside its terminal privacy handling. +Do not use EdgeZero's standard collecting converter for this lazy path or broaden +unrelated header policy. Core publisher tests assert intent and identity output; +the adapter tests assert the final Fastly hint and framing. + +Delivery hints must not alter stored templates or origin cache identities. +Template admission, key construction, origin readthrough, TTLs, and purge remain +unchanged. Reader-specific HTML must remain private under the current terminal +privacy rules, including existing stricter `no-store` paths. + +## Failure handling and compatibility + +Keep existing decode errors, truncation handling, byte limits, cancellation, +auction abandonment, and unsupported-encoding fallbacks. Add no retry, new size +limit, or alternative buffering path. Preserve lazy origin consumption and the +article-prefix-before-auction behavior. + +The old `publisher.ssat_compression_offload_enabled` setting never landed on +`main`; remove its branch-only implementation, tests, and example documentation. +This change introduces no configuration migration. Other adapters retain their +current output, and eligible Fastly deployments change automatically on upgrade. +Rollback is to the preceding application release, not a new runtime flag. + +The Fastly adapter's current 10 MiB origin-response materialization check applies +to buffered responses, not its streamed-response branch. Publisher pipeline +limits remain separate. This change does not request larger identity origin +responses and does not change any limit. + +## Scenarios and acceptance + +| Scenario | Expected outcome | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Fastly eligible inline HTML, identity/gzip/deflate/Brotli origin | Same decoded rewritten HTML, guest identity output, delivery hint, private response | +| Fastly eligible buffered HTML | Same encoding and header contract without changing processor ordering | +| Eligible Fastly template miss, warm hit, and assembly fallback | Guest identity output; unchanged template bytes, admission, and privacy | +| Ineligible reader of an existing template | Current encoding, assembly, consent gates, and terminal privacy | +| Axum, Cloudflare, or Spin, including with Fastly-style cache headers | Existing encoding; capability cannot be inferred from cache headers | +| Ad stack ineligible or non-HTML response | Existing compression and behavior | +| Unsupported origin encoding | Existing unmodified bypass | +| HEAD, 204, 205, 304, redirects, and errors | Existing body/framing behavior; no newly activated offload | +| Deflate-selected or failed reader negotiation | Existing in-guest delivery, no new offload hint | +| Existing multi-field Vary or wildcard | Fields preserved, no duplicate required variation | +| Streaming origin and slow auction | Lazy body consumption and prefix delivery remain intact | +| Decode failure or byte-limit violation | Existing failure behavior and auction cleanup | + +Local tests must observe origin request headers, decoded body equivalence, +response framing, compression metadata, terminal privacy, and adapter capability +isolation through current handler/finalizer paths. Add focused regressions before +running affected neighboring suites and the repository CI gates. Existing tests +can cover unchanged behavior where their inputs remain applicable. + +## Live validation + +Viceroy can prove guest bytes and headers but not Fastly dynamic compression. +Before claiming production performance or final reader negotiation, measure on +Fastly staging with representative normal and large pages: + +- inline, template miss, and template hit paths; +- guest vCPU, TTFB, origin transfer bytes, and final browser transfer bytes; +- browser `Content-Encoding` and decoded HTML equivalence; +- absent `Accept-Encoding`, identity-only, supported compressed encodings, and + identity-refusing requests; +- unchanged response privacy and no shared caching of assembled HTML. + +Keep this evidence distinct from local regression results. Requesting identity +from origin is a separate future experiment, not an acceptance requirement here. + +## Platform reference + +Fastly's `X-Compress-Hint` documentation specifies streaming compression selected +from the matching reader request, gzip/Brotli support, automatic preference for +Brotli, removal of the hint, and removal of `Content-Length` when compression +occurs. See the [header reference](https://www.fastly.com/documentation/reference/http/http-headers/X-Compress-Hint/). +This documents the intended contract; staging must still verify this application. + +## Review log + +- Round 1, independent agent using `openai-codex/gpt-6.1-sol`: no blocking + design defects. Clarified per-reader template eligibility, the current + `HtmlEofDriver`, explicit successful/body-bearing offload gating independent + of the rewrite classifier, wildcard Vary, final-send header checks, and valid + recomputed buffered lengths. The reviewer confirmed deflate and failed-reader + negotiation must retain existing delivery behavior. +- Round 2, fresh agent session using `openai-codex/gpt-6.1-sol`: source check + confirmed the clarifications and found no blocking design gaps. Identified + late Fastly response effects as a required send-time boundary; added a typed + offload marker and narrow terminal reassertion for both send forms, with a + focused regression. No broader header-policy change. +- 2026-11-02 framework extraction: two independent EdgeZero design checks confirmed + the shared capability/intent/finalizer contract and identified the old early + application markers as unsuitable for checked marking. This revision replaces + them with a pending application output plan and marks only installed identity + output. +- Consumer contract review, 2026-11-02: the implementation worker and a fresh + independent `openai-codex/gpt-6.1-sol` session checked this revised spec and plan + against rebased application source and published EdgeZero commit `63c7b63`. + Both found no blockers. This contract review alone is not runtime proof. +- Consumer implementation review, 2026-11-02: a fresh independent + `openai-codex/gpt-6.1-sol` session found no blockers after tracing the pending + output plan, template and EOF encoders, post-install marking, and final Fastly + effects. All ten runtime input hashes matched at completion. This was a + source-only review; execution results belong to the implementation plan.