From db27e738d368b5231e2e1611f8f29c70a3b66847 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 2 Nov 2026 15:32:17 -0600 Subject: [PATCH 1/3] Offload SSAT delivery compression through EdgeZero Consume the exact framework PR revision while both PRs remain draft. Keep eligibility and output planning in publisher code and apply shared Fastly finalization after late effects. --- Cargo.lock | 16 +- Cargo.toml | 12 +- .../trusted-server-adapter-fastly/src/main.rs | 135 +++- crates/trusted-server-core/src/publisher.rs | 152 ++++- .../publisher/delivery_compression_tests.rs | 616 ++++++++++++++++++ .../src/publisher/html_finalization.rs | 2 +- docs/guide/configuration.md | 22 + ...11-02-858-edgezero-delivery-compression.md | 101 +++ ...sat-delivery-compression-offload-design.md | 228 +++++++ 9 files changed, 1241 insertions(+), 43 deletions(-) create mode 100644 crates/trusted-server-core/src/publisher/delivery_compression_tests.rs create mode 100644 docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md create mode 100644 docs/superpowers/specs/2026-10-29-858-ssat-delivery-compression-offload-design.md diff --git a/Cargo.lock b/Cargo.lock index 70316712ac..7077570f8e 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=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" 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=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" 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=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" 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=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" 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=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" 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=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" 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=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" 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=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" dependencies = [ "log", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 139cf4f610..8bfcc51e0c 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 = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9" } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", 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..8bab940c7a --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md @@ -0,0 +1,101 @@ +# 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 + `63c7b63bbbaab7cc1262162d96987e61c146fbf9`. 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. + +Local 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. + +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..96cb8ef0a8 --- /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 +`63c7b63bbbaab7cc1262162d96987e61c146fbf9` 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. From 5298c0a20b664394d6ec218862296ca1ba9463b8 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 3 Nov 2026 13:52:23 -0600 Subject: [PATCH 2/3] Refresh EdgeZero pin after Vary test review --- Cargo.lock | 16 +++++++------- Cargo.toml | 12 +++++------ ...11-02-858-edgezero-delivery-compression.md | 21 +++++++++++++++++-- ...sat-delivery-compression-offload-design.md | 2 +- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7077570f8e..34a4d9a1b9 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?rev=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" +source = "git+https://github.com/IABTechLab/edgezero?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" 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?rev=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" +source = "git+https://github.com/IABTechLab/edgezero?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" 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?rev=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" +source = "git+https://github.com/IABTechLab/edgezero?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" 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?rev=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" +source = "git+https://github.com/IABTechLab/edgezero?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" 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?rev=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" +source = "git+https://github.com/IABTechLab/edgezero?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" dependencies = [ "anyhow", "async-trait", @@ -1651,7 +1651,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?rev=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" +source = "git+https://github.com/IABTechLab/edgezero?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" dependencies = [ "chrono", "clap", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?rev=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" +source = "git+https://github.com/IABTechLab/edgezero?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" dependencies = [ "anyhow", "async-compression", @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?rev=63c7b63bbbaab7cc1262162d96987e61c146fbf9#63c7b63bbbaab7cc1262162d96987e61c146fbf9" +source = "git+https://github.com/IABTechLab/edgezero?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" dependencies = [ "log", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 8bfcc51e0c..509452203f 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", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } -edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9" } -edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "63c7b63bbbaab7cc1262162d96987e61c146fbf9", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } env_logger = "0.11" error-stack = "0.6" esi = "0.7.2" 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 index 8bab940c7a..b8877d1872 100644 --- a/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md +++ b/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md @@ -22,7 +22,7 @@ Fastly's final delivery headers. ## Implementation 1. Pin all six root workspace EdgeZero dependencies to - `63c7b63bbbaab7cc1262162d96987e61c146fbf9`. Regenerate only the necessary lock + `1c0f475097a813a3a9cbb7a4a121ca0523d5ed64`. 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. @@ -75,7 +75,9 @@ 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. -Local checks passed with Rust 1.98.1, Node 24.20.0 and Viceroy 0.21.0: +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 @@ -97,5 +99,20 @@ 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. + 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 index 96cb8ef0a8..9c75c702f8 100644 --- 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 @@ -54,7 +54,7 @@ No Fastly SDK dependency enters core. 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 -`63c7b63bbbaab7cc1262162d96987e61c146fbf9` while the paired PRs are under review. +`1c0f475097a813a3a9cbb7a4a121ca0523d5ed64` 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. From 85a9b9184a67a9c827be9b59ead95a801fb07faf Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 3 Nov 2026 14:43:20 -0600 Subject: [PATCH 3/3] Refresh EdgeZero pin after smoke fixture fix --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 12 ++++++------ ...26-11-02-858-edgezero-delivery-compression.md | 16 +++++++++++++++- ...8-ssat-delivery-compression-offload-design.md | 2 +- 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34a4d9a1b9..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?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" +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?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" +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?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" +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?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" +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?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" +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?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" +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?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" +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?rev=1c0f475097a813a3a9cbb7a4a121ca0523d5ed64#1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" +source = "git+https://github.com/IABTechLab/edgezero?rev=157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec#157f091e42b5c584898a5d0f8a0d3ddc3a4dd5ec" dependencies = [ "log", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 509452203f..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", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", default-features = false } -edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64" } -edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "1c0f475097a813a3a9cbb7a4a121ca0523d5ed64", 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/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md b/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md index b8877d1872..be11659a4b 100644 --- a/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md +++ b/docs/superpowers/plans/2026-11-02-858-edgezero-delivery-compression.md @@ -22,7 +22,7 @@ Fastly's final delivery headers. ## Implementation 1. Pin all six root workspace EdgeZero dependencies to - `1c0f475097a813a3a9cbb7a4a121ca0523d5ed64`. Regenerate only the necessary lock + `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. @@ -114,5 +114,19 @@ 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 index 9c75c702f8..28e434daba 100644 --- 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 @@ -54,7 +54,7 @@ No Fastly SDK dependency enters core. 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 -`1c0f475097a813a3a9cbb7a4a121ca0523d5ed64` while the paired PRs are under review. +`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.