diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 9e81d67d..c5488c44 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -1,5 +1,8 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use trusted_server_core::html_processor::{create_html_processor, HtmlProcessorConfig}; +use trusted_server_core::html_processor::{ + create_html_processor, BidInjectionMode, HtmlPostProcessingMode, HtmlProcessorConfig, +}; +use trusted_server_core::integrations::IntegrationDocumentState; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::streaming_processor::StreamProcessor as _; @@ -12,6 +15,9 @@ fn make_config() -> HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index a3170084..884214aa 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -19,6 +19,7 @@ use crate::integrations::{ IntegrationScriptContext, ScriptRewriteAction, }; use crate::publisher::build_empty_bids_script; +use crate::publisher_late_binding::HtmlInjectionTracker; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; @@ -92,66 +93,103 @@ impl StreamProcessor for HtmlWithPostProcessing { return Ok(Vec::new()); } - // Final chunk: run post-processors on the full accumulated output. - let full_output = std::mem::take(&mut self.accumulated_output); - if full_output.is_empty() { - return Ok(full_output); - } + run_html_post_processors( + std::mem::take(&mut self.accumulated_output), + &self.post_processors, + &self.origin_host, + &self.request_host, + &self.request_scheme, + &self.document_state, + self.max_buffered_body_bytes, + ) + } - let Ok(output_str) = std::str::from_utf8(&full_output) else { - return Ok(full_output); - }; + /// No-op. `HtmlWithPostProcessing` wraps a single-use + /// [`HtmlRewriterAdapter`] that cannot be reset. Clearing auxiliary + /// state without resetting the rewriter would leave the processor + /// in an inconsistent state, so this method intentionally does nothing. + fn reset(&mut self) {} +} - let ctx = IntegrationHtmlContext { - request_host: &self.request_host, - request_scheme: &self.request_scheme, - origin_host: &self.origin_host, - document_state: &self.document_state, - }; +/// Run registered full-document post-processors on already rewritten HTML. +pub(crate) fn run_html_post_processors( + full_output: Vec, + post_processors: &[Arc], + origin_host: &str, + request_host: &str, + request_scheme: &str, + document_state: &IntegrationDocumentState, + max_buffered_body_bytes: usize, +) -> Result, io::Error> { + if full_output.is_empty() || post_processors.is_empty() { + return Ok(full_output); + } - // Preflight to avoid allocating a `String` unless at least one post-processor wants to run. - if !self - .post_processors - .iter() - .any(|p| p.should_process(output_str, &ctx)) - { - return Ok(full_output); - } + let Ok(output_str) = std::str::from_utf8(&full_output) else { + return Ok(full_output); + }; - let mut html = String::from_utf8(full_output).map_err(|e| { - io::Error::other(format!( - "HTML post-processing expected valid UTF-8 output: {e}" - )) - })?; + let ctx = IntegrationHtmlContext { + request_host, + request_scheme, + origin_host, + document_state, + }; - let mut changed = false; - for processor in &self.post_processors { - if processor.should_process(&html, &ctx) { - changed |= processor.post_process(&mut html, &ctx); - } - } + if !post_processors + .iter() + .any(|processor| processor.should_process(output_str, &ctx)) + { + return Ok(full_output); + } - if changed { - log::debug!("HTML post-processing complete: output_len={}", html.len()); - } + let mut html = String::from_utf8(full_output).map_err(|e| { + io::Error::other(format!( + "HTML post-processing expected valid UTF-8 output: {e}" + )) + })?; - // Post-processors may append content (e.g. injected scripts); enforce the - // same cap on the final document so growth during post-processing cannot - // push the buffer past the limit either. - if html.len() > self.max_buffered_body_bytes { - return Err(io::Error::other( - "publisher body exceeded maximum buffered size", - )); + let mut changed = false; + for processor in post_processors { + if processor.should_process(&html, &ctx) { + changed |= processor.post_process(&mut html, &ctx); } + } - Ok(html.into_bytes()) + if changed { + log::debug!("HTML post-processing complete: output_len={}", html.len()); } - /// No-op. `HtmlWithPostProcessing` wraps a single-use - /// [`HtmlRewriterAdapter`] that cannot be reset. Clearing auxiliary - /// state without resetting the rewriter would leave the processor - /// in an inconsistent state, so this method intentionally does nothing. - fn reset(&mut self) {} + if html.len() > max_buffered_body_bytes { + return Err(io::Error::other( + "publisher body exceeded maximum buffered size", + )); + } + + Ok(html.into_bytes()) +} + +/// How SSAT bids are inserted into parser-confirmed body end tags. +#[derive(Clone)] +pub enum BidInjectionMode { + /// Read the current bids script from shared state at the body end tag. + DirectState, + /// Insert an opaque placeholder for a later parser-safe replacement pass. + Placeholder { + /// Placeholder HTML inserted before the body end tag. + html: String, + /// Shared tracker used for EOF fallback decisions. + tracker: Arc, + }, +} + +/// Whether full-document HTML post-processors run inside this processor. +#[derive(Clone, Copy)] +pub enum HtmlPostProcessingMode { + /// Run registered full-document post-processors at EOF. + Enabled, + /// Skip post-processors so callers can run them after bid late binding. + Disabled, } /// Configuration for HTML processing @@ -172,6 +210,13 @@ pub struct HtmlProcessorConfig { /// processor aborts. Mirrors `publisher.max_buffered_body_bytes` so the /// full-document buffering done for post-processors is bounded. pub max_buffered_body_bytes: usize, + /// Bid insertion strategy for parser-confirmed body end tags. + pub bid_injection_mode: BidInjectionMode, + /// Controls whether full-document post-processors run inside this processor. + pub post_processing_mode: HtmlPostProcessingMode, + /// Per-document integration state shared by script rewriters and optional + /// post-processors. + pub document_state: IntegrationDocumentState, } impl HtmlProcessorConfig { @@ -192,6 +237,9 @@ impl HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } @@ -212,6 +260,65 @@ impl HtmlProcessorConfig { self.ad_bids_state = ad_bids_state; self } + + /// Insert `placeholder_html` at the parser-confirmed body end tag instead + /// of reading bids directly from shared state. + #[must_use] + pub fn with_bid_placeholder( + mut self, + placeholder_html: String, + tracker: Arc, + ) -> Self { + self.bid_injection_mode = BidInjectionMode::Placeholder { + html: placeholder_html, + tracker, + }; + self + } + + /// Disable full-document post-processors for a caller-managed buffered pass. + #[must_use] + pub fn without_post_processing(mut self) -> Self { + self.post_processing_mode = HtmlPostProcessingMode::Disabled; + self + } + + /// Use caller-provided document state for multi-phase processing. + #[must_use] + pub fn with_document_state(mut self, document_state: IntegrationDocumentState) -> Self { + self.document_state = document_state; + self + } +} + +/// Build the executable snippet normally inserted at the start of ``. +#[must_use] +pub(crate) fn build_head_bootstrap_snippet( + integrations: &IntegrationRegistry, + origin_host: &str, + request_host: &str, + request_scheme: &str, + document_state: &IntegrationDocumentState, + ad_slots_script: Option<&str>, +) -> String { + let mut snippet = String::new(); + if let Some(slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } + let ctx = IntegrationHtmlContext { + request_host, + request_scheme, + origin_host, + document_state, + }; + for insert in integrations.head_inserts(&ctx) { + snippet.push_str(&insert); + } + let immediate_ids = integrations.js_module_ids_immediate(); + snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); + let deferred_ids = integrations.js_module_ids_deferred(); + snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids)); + snippet } /// Create an HTML processor with URL replacement and integration hooks. @@ -222,8 +329,11 @@ impl HtmlProcessorConfig { /// normal operation since no code holds the lock across a panic boundary. #[must_use] pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { - let post_processors = config.integrations.html_post_processors(); - let document_state = IntegrationDocumentState::default(); + let post_processors = match config.post_processing_mode { + HtmlPostProcessingMode::Enabled => config.integrations.html_post_processors(), + HtmlPostProcessingMode::Disabled => Vec::new(), + }; + let document_state = config.document_state.clone(); // Simplified URL patterns structure - stores only core data and generates variants on-demand struct UrlPatterns { @@ -294,6 +404,8 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); let ad_bids_state = config.ad_bids_state.clone(); + let bid_injection_mode = config.bid_injection_mode.clone(); + let head_bid_injection_mode = bid_injection_mode.clone(); let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -304,33 +416,29 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); move |el| { - if !injected_tsjs.get() { - let mut snippet = String::new(); - // Inject ad slots script first so it appears before tsjs bundle. - if let Some(ref slots_script) = ad_slots_script { - snippet.push_str(slots_script); - } - let ctx = IntegrationHtmlContext { - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - document_state: &document_state, - }; - // First inject integration-specific config (e.g., window.__tsjs_prebid) - // so it's available when the bundle's auto-init code reads it. - for insert in integrations.head_inserts(&ctx) { - snippet.push_str(&insert); + if injected_tsjs.get() { + return Ok(()); + } + if let BidInjectionMode::Placeholder { tracker, .. } = &head_bid_injection_mode { + if tracker.head_injected() || tracker.bid_placeholder_inserted() { + injected_tsjs.set(true); + return Ok(()); } - // Main bundle: core + non-deferred integrations (synchronous). - let immediate_ids = integrations.js_module_ids_immediate(); - snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); - // Deferred bundles: large modules like prebid loaded after - // HTML parsing completes. Empty when none are enabled. - let deferred_ids = integrations.js_module_ids_deferred(); - snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids)); - el.prepend(&snippet, ContentType::Html); - injected_tsjs.set(true); } + + let snippet = build_head_bootstrap_snippet( + &integrations, + &patterns.origin_host, + &patterns.request_host, + &patterns.request_scheme, + &document_state, + ad_slots_script.as_deref(), + ); + if let BidInjectionMode::Placeholder { tracker, .. } = &head_bid_injection_mode { + tracker.mark_head_injected(); + } + el.prepend(&snippet, ContentType::Html); + injected_tsjs.set(true); Ok(()) } }), @@ -345,24 +453,34 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let state = ad_bids_state.clone(); let injected_bids = injected_bids.clone(); let has_slots = ad_slots_script.is_some(); + let bid_injection_mode = bid_injection_mode.clone(); move |el| { if !has_slots { return Ok(()); } let state = state.clone(); let injected_bids = injected_bids.clone(); + let bid_injection_mode = bid_injection_mode.clone(); if let Some(handlers) = el.end_tag_handlers() { let handler: EndTagHandler<'static> = Box::new(move |end_tag: &mut EndTag<'_>| { if injected_bids.swap(true, Ordering::SeqCst) { return Ok(()); } - let script_guard = state.lock().expect("should lock bid state"); - let bids_script = match &*script_guard { - Some(s) => s.clone(), - None => build_empty_bids_script(), - }; - end_tag.before(&bids_script, ContentType::Html); + match &bid_injection_mode { + BidInjectionMode::DirectState => { + let script_guard = state.lock().expect("should lock bid state"); + let bids_script = match &*script_guard { + Some(s) => s.clone(), + None => build_empty_bids_script(), + }; + end_tag.before(&bids_script, ContentType::Html); + } + BidInjectionMode::Placeholder { html, tracker } => { + tracker.mark_bid_placeholder_inserted(); + end_tag.before(html, ContentType::Html); + } + } Ok(()) }); handlers.push(handler); @@ -664,6 +782,9 @@ mod tests { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } @@ -1436,6 +1557,9 @@ mod tests { ), ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1509,6 +1633,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1544,6 +1671,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); // Malformed HTML with two elements (common in CMS template pages) @@ -1578,6 +1708,9 @@ mod tests { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1630,6 +1763,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1656,6 +1792,9 @@ mod tests { ad_slots_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 48e92fae..a47a63bd 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -59,6 +59,7 @@ pub mod platform; pub mod price_bucket; pub mod proxy; pub mod publisher; +pub mod publisher_late_binding; pub mod redacted; pub mod request_signing; pub mod response_privacy; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index acf92959..1262676d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -18,7 +18,7 @@ //! into any [`Write`] (a `Vec` for buffered routes, a streaming writer for //! the streaming route). It is not a content-rewriting concern. -use std::io::Write; +use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -54,6 +54,10 @@ use crate::http_util::{is_navigation_request, serve_static_with_etag, RequestInf use crate::integrations::IntegrationRegistry; use crate::platform::{GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; use crate::price_bucket::{price_bucket, PriceGranularity}; +use crate::publisher_late_binding::{ + BidPlaceholder, HtmlInjectionTracker, PlaceholderLateBinder, PlaceholderScan, + SSAT_HELD_TAIL_CAP_BYTES, +}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -345,16 +349,56 @@ fn create_html_stream_processor( ad_slots_script: Option, ad_bids_state: Arc>>, ) -> Result> { - use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; - - let config = HtmlProcessorConfig::from_settings( - settings, - integration_registry, + create_html_stream_processor_with_options(HtmlStreamProcessorOptions { origin_host, request_host, request_scheme, + settings, + integration_registry, + ad_slots_script, + ad_bids_state, + late_binding: None, + }) +} + +struct HtmlLateBindingOptions<'a> { + placeholder: &'a BidPlaceholder, + tracker: Arc, +} + +struct HtmlStreamProcessorOptions<'a> { + origin_host: &'a str, + request_host: &'a str, + request_scheme: &'a str, + settings: &'a Settings, + integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option, + ad_bids_state: Arc>>, + late_binding: Option>, +} + +fn create_html_stream_processor_with_options( + options: HtmlStreamProcessorOptions<'_>, +) -> Result> { + use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; + + let mut config = HtmlProcessorConfig::from_settings( + options.settings, + options.integration_registry, + options.origin_host, + options.request_host, + options.request_scheme, ) - .with_ad_state(ad_slots_script, ad_bids_state); + .with_ad_state(options.ad_slots_script, options.ad_bids_state); + + if let Some(late_binding) = options.late_binding { + config = config + .with_bid_placeholder( + late_binding.placeholder.as_html().to_owned(), + late_binding.tracker, + ) + .without_post_processing(); + } Ok(create_html_processor(config)) } @@ -677,27 +721,24 @@ pub fn stream_publisher_body( process_response_streaming(body, output, &borrowed) } -/// Stream publisher body with a `` syntax. /// -/// 1. [`collect_dispatched_auction`](AuctionOrchestrator::collect_dispatched_auction) -/// is awaited with the remaining deadline. -/// 2. Winning bids are written to `ad_bids_state`. -/// 3. The held tail is fed through the pipeline so `lol_html` fires its -/// `` handler with bids now in state. -/// -/// For non-HTML content types the auction is collected before any body bytes -/// are written (no `` to inject). If `params.dispatched_auction` is -/// `None` the function falls back to the synchronous -/// [`stream_publisher_body`] path. +/// For non-HTML content types, any dispatched auction is collected before body +/// bytes are written because there is no parser-confirmed body close for bid +/// insertion. If no SSAT slots matched this HTML response, the function falls +/// back to the synchronous pipeline unchanged. /// /// # Errors /// -/// Returns an error if processing fails mid-stream. Headers are already -/// committed at that point; the caller logs and drops the `StreamingBody`. +/// Returns an error if processing fails mid-stream. Headers may already be +/// committed at that point; streaming callers should log the error and drop the +/// client body. pub async fn stream_publisher_body_async( body: EdgeBody, output: &mut W, @@ -707,76 +748,100 @@ pub async fn stream_publisher_body_async( orchestrator: &AuctionOrchestrator, services: &RuntimeServices, ) -> Result<(), Report> { - let Some(dispatched) = params.dispatched_auction.take() else { - // No auction — use the existing sync pipeline unchanged. - return stream_publisher_body(body, output, params, settings, integration_registry); - }; + let dispatched = params.dispatched_auction.take(); let telemetry = AuctionTelemetryCarry { observation: params.auction_observation.take(), auction_request: params.auction_request.take(), }; let is_html = is_html_content_type(¶ms.content_type); - - if !is_html { - // Non-HTML: collect auction first, then stream. There is no - // to hold, so delaying the entire body until collection is acceptable. - let placeholder = mediator_placeholder_request(); - let result = orchestrator - .collect_dispatched_auction( - dispatched, - services, - &make_collect_context(settings, services, &placeholder), - ) - .await; - if let (Some(observation), Some(auction_request)) = - (telemetry.observation, telemetry.auction_request.as_ref()) - { - emit_auction_events_best_effort_lazy(services, || { - build_auction_events( - observation, - AuctionTerminalOutcome::Completed { - request: auction_request, - result: &result, - }, + let needs_ssat_late_binding = is_html && params.ad_slots_script.is_some(); + + if !needs_ssat_late_binding { + if let Some(dispatched) = dispatched { + // Non-HTML or no-slot responses have no SSAT body-close injection + // point. Collect before writing bytes so telemetry completes and the + // existing direct-state pipeline can preserve legacy behavior. + let placeholder = mediator_placeholder_request(); + let result = orchestrator + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), ) - }) - .await; - } + .await; + if let (Some(observation), Some(auction_request)) = + (telemetry.observation, telemetry.auction_request.as_ref()) + { + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: auction_request, + result: &result, + }, + ) + }) + .await; + } - write_bids_to_state( - &result.winning_bids, - params.price_granularity, - ¶ms.ad_bids_state, - settings.debug.inject_adm_for_testing, - ); + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, + ); + } return stream_publisher_body(body, output, params, settings, integration_registry); } - // HTML: build the processor once and drive it chunk by chunk. - // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin - // EOF, then await auction and process chunk N (which contains ). - let mut processor = match create_html_stream_processor( - ¶ms.origin_host, - ¶ms.request_host, - ¶ms.request_scheme, - settings, - integration_registry, - params.ad_slots_script.as_deref().map(str::to_string), - params.ad_bids_state.clone(), - ) { - Ok(processor) => processor, - Err(err) => { - emit_abandoned_auction( + if integration_registry.has_html_post_processors() { + return buffer_html_late_binding_with_postprocessors( + body, + output, + BufferedLateBindingContext { + params, + settings, + integration_registry, + orchestrator, services, - telemetry.observation, dispatched, - "processor_init_error", - ) - .await; - return Err(err); - } - }; + telemetry, + }, + ) + .await; + } + + let placeholder = BidPlaceholder::new(); + let tracker = Arc::new(HtmlInjectionTracker::default()); + let mut processor = + match create_html_stream_processor_with_options(HtmlStreamProcessorOptions { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + settings, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + late_binding: Some(HtmlLateBindingOptions { + placeholder: &placeholder, + tracker: Arc::clone(&tracker), + }), + }) { + Ok(processor) => processor, + Err(err) => { + if let Some(dispatched) = dispatched { + emit_abandoned_auction( + services, + telemetry.observation, + dispatched, + "processor_init_error", + ) + .await; + } + return Err(err); + } + }; let compression = Compression::from_content_encoding(¶ms.content_encoding); stream_html_with_auction_hold( @@ -793,10 +858,289 @@ pub async fn stream_publisher_body_async( services, settings, }, + LateBindingStreamConfig { + placeholder: &placeholder, + tracker: Arc::clone(&tracker), + fallback: LateBindingFallbackContext { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + }, + }, ) .await } +struct BufferedLateBindingContext<'a> { + params: &'a OwnedProcessResponseParams, + settings: &'a Settings, + integration_registry: &'a IntegrationRegistry, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + dispatched: Option, + telemetry: AuctionTelemetryCarry, +} + +async fn buffer_html_late_binding_with_postprocessors( + body: EdgeBody, + output: &mut W, + mut ctx: BufferedLateBindingContext<'_>, +) -> Result<(), Report> { + use crate::html_processor::{ + create_html_processor, run_html_post_processors, HtmlProcessorConfig, + }; + use crate::integrations::IntegrationDocumentState; + + let compression = Compression::from_content_encoding(&ctx.params.content_encoding); + let decoded = match decode_body_to_vec( + body, + compression, + ctx.settings.publisher.max_buffered_body_bytes, + ) { + Ok(decoded) => decoded, + Err(err) => { + if let Some(dispatched) = ctx.dispatched.take() { + emit_abandoned_auction( + ctx.services, + ctx.telemetry.observation.take(), + dispatched, + "buffered_decode_error", + ) + .await; + } + return Err(err); + } + }; + + let placeholder = BidPlaceholder::new(); + let tracker = Arc::new(HtmlInjectionTracker::default()); + let document_state = IntegrationDocumentState::default(); + let config = HtmlProcessorConfig::from_settings( + ctx.settings, + ctx.integration_registry, + &ctx.params.origin_host, + &ctx.params.request_host, + &ctx.params.request_scheme, + ) + .with_ad_state( + ctx.params.ad_slots_script.clone(), + Arc::clone(&ctx.params.ad_bids_state), + ) + .with_bid_placeholder(placeholder.as_html().to_owned(), Arc::clone(&tracker)) + .without_post_processing() + .with_document_state(document_state.clone()); + let mut processor = create_html_processor(config); + let held_tail_cap = + SSAT_HELD_TAIL_CAP_BYTES.min(ctx.settings.publisher.max_buffered_body_bytes); + let mut binder = PlaceholderLateBinder::new(&placeholder, held_tail_cap); + let mut late_bound = Vec::new(); + let fallback = LateBindingFallbackContext { + origin_host: &ctx.params.origin_host, + request_host: &ctx.params.request_host, + request_scheme: &ctx.params.request_scheme, + integration_registry: ctx.integration_registry, + ad_slots_script: ctx.params.ad_slots_script.as_deref(), + }; + let mut state = LateBindingState { + dispatched: ctx.dispatched, + telemetry: ctx.telemetry, + price_granularity: ctx.params.price_granularity, + ad_bids_state: &ctx.params.ad_bids_state, + orchestrator: ctx.orchestrator, + services: ctx.services, + settings: ctx.settings, + tracker: Arc::clone(&tracker), + fallback, + decoded_input_bytes: decoded.len(), + processed_output_bytes: 0, + }; + + for chunk in decoded.chunks(STREAM_CHUNK_SIZE) { + let processed = + processor + .process_chunk(chunk, false) + .change_context(TrustedServerError::Proxy { + message: "Failed to process buffered publisher HTML".to_string(), + }); + let processed = match processed { + Ok(processed) => processed, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "buffered_process_error").await; + return Err(err); + } + }; + write_late_bound_processed_output(&mut late_bound, &mut binder, &processed, &mut state) + .await?; + } + + let final_out = processor + .process_chunk(&[], true) + .change_context(TrustedServerError::Proxy { + message: "Failed to process buffered publisher HTML".to_string(), + }); + let final_out = match final_out { + Ok(final_out) => final_out, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "buffered_process_error").await; + return Err(err); + } + }; + write_late_bound_processed_output(&mut late_bound, &mut binder, &final_out, &mut state).await?; + let found_placeholder = binder.replaced(); + let remainder = binder.finish(); + write_checked_processed_output_or_abandon( + &mut late_bound, + &remainder, + &mut state, + "processed_output_error", + ) + .await?; + if !found_placeholder { + if state.tracker.bid_placeholder_inserted() { + log::warn!( + "SSAT bid placeholder was inserted but not observed in buffered output; using EOF fallback" + ); + } + resolve_late_bound_bids(&mut state).await; + let tail = build_eof_fallback_tail( + state.tracker.head_injected(), + &state.fallback, + state.ad_bids_state, + ); + write_checked_processed_output_or_abandon( + &mut late_bound, + tail.as_bytes(), + &mut state, + "processed_output_error", + ) + .await?; + } + + let post_processors = ctx.integration_registry.html_post_processors(); + let post_processed = run_html_post_processors( + late_bound, + &post_processors, + &ctx.params.origin_host, + &ctx.params.request_host, + &ctx.params.request_scheme, + &document_state, + ctx.settings.publisher.max_buffered_body_bytes, + ) + .change_context(TrustedServerError::Proxy { + message: "Failed to post-process buffered publisher HTML".to_string(), + })?; + + encode_processed_html_to_writer(&post_processed, compression, output) +} + +fn decode_body_to_vec( + body: EdgeBody, + compression: Compression, + limit: usize, +) -> Result, Report> { + use brotli::Decompressor; + use flate2::read::{GzDecoder, ZlibDecoder}; + + let body = body_as_reader(body); + match compression { + Compression::None => read_to_vec_bounded(body, limit), + Compression::Gzip => read_to_vec_bounded(GzDecoder::new(body), limit), + Compression::Deflate => read_to_vec_bounded(ZlibDecoder::new(body), limit), + Compression::Brotli => { + read_to_vec_bounded(Decompressor::new(body, STREAM_CHUNK_SIZE), limit) + } + } +} + +fn read_to_vec_bounded( + mut reader: R, + limit: usize, +) -> Result, Report> { + let mut output = Vec::new(); + let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return Ok(output), + Ok(n) => { + if output.len() + n > limit { + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher decoded input exceeded maximum buffered size" + .to_string(), + })); + } + output.extend_from_slice(&buffer[..n]); + } + Err(err) => { + return Err(Report::new(TrustedServerError::Proxy { + message: format!("Failed to decode publisher body: {err}"), + })); + } + } + } +} + +fn encode_processed_html_to_writer( + bytes: &[u8], + compression: Compression, + output: &mut W, +) -> Result<(), Report> { + use brotli::enc::writer::CompressorWriter; + use brotli::enc::BrotliEncoderParams; + use flate2::write::{GzEncoder, ZlibEncoder}; + + match compression { + Compression::None => output + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write buffered publisher HTML".to_string(), + }), + Compression::Gzip => { + let mut encoder = GzEncoder::new(output, flate2::Compression::default()); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write gzip publisher HTML".to_string(), + })?; + encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip encoder".to_string(), + })?; + Ok(()) + } + Compression::Deflate => { + let mut encoder = ZlibEncoder::new(output, flate2::Compression::default()); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write deflate publisher HTML".to_string(), + })?; + encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate encoder".to_string(), + })?; + Ok(()) + } + Compression::Brotli => { + let params = BrotliEncoderParams { + quality: 4, + lgwin: 22, + ..Default::default() + }; + let mut encoder = CompressorWriter::with_params(output, STREAM_CHUNK_SIZE, ¶ms); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write brotli publisher HTML".to_string(), + })?; + encoder.flush().change_context(TrustedServerError::Proxy { + message: "Failed to finalize brotli encoder".to_string(), + })?; + let _ = encoder.into_inner(); + Ok(()) + } + } +} + /// Builds the canonical mediator placeholder [`Request`] passed to the collect /// phase via [`make_collect_context`]. /// @@ -964,7 +1308,7 @@ impl AuctionTelemetryCarry { /// Bundles the auction-collection dependencies passed through the streaming helpers. struct AuctionCollectCtx<'a> { - dispatched: DispatchedAuction, + dispatched: Option, telemetry: AuctionTelemetryCarry, price_granularity: PriceGranularity, ad_bids_state: &'a Arc>>, @@ -973,14 +1317,29 @@ struct AuctionCollectCtx<'a> { settings: &'a Settings, } -/// Run the close-body hold loop for HTML bodies, collecting the auction before -/// the raw ` { + origin_host: &'a str, + request_host: &'a str, + request_scheme: &'a str, + integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option<&'a str>, +} + +struct LateBindingStreamConfig<'a> { + placeholder: &'a BidPlaceholder, + tracker: Arc, + fallback: LateBindingFallbackContext<'a>, +} + +/// Run the parser-safe placeholder late-binding loop for HTML bodies. async fn stream_html_with_auction_hold( body: EdgeBody, output: &mut W, processor: &mut P, compression: Compression, ctx: AuctionCollectCtx<'_>, + late_binding: LateBindingStreamConfig<'_>, ) -> Result<(), Report> { use brotli::enc::writer::CompressorWriter; use brotli::enc::BrotliEncoderParams; @@ -988,13 +1347,36 @@ async fn stream_html_with_auction_hold( use flate2::read::{GzDecoder, ZlibDecoder}; use flate2::write::{GzEncoder, ZlibEncoder}; + let placeholder = late_binding.placeholder; + let tracker = late_binding.tracker; + let fallback_ctx = late_binding.fallback; let body = body_as_reader(body); match compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, + Compression::None => { + body_close_hold_loop( + body, + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } Compression::Gzip => { let decoder = GzDecoder::new(body); let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip encoder".to_string(), })?; @@ -1003,7 +1385,16 @@ async fn stream_html_with_auction_hold( Compression::Deflate => { let decoder = ZlibDecoder::new(body); let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize deflate encoder".to_string(), })?; @@ -1018,150 +1409,154 @@ async fn stream_html_with_auction_hold( }; let mut encoder = CompressorWriter::with_params(&mut *output, STREAM_CHUNK_SIZE, ¶ms); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; let _ = encoder.into_inner(); Ok(()) } } } -const BODY_CLOSE_PREFIX: &[u8] = b", - found_close: bool, -} - -impl BodyCloseHoldBuffer { - fn new() -> Self { - Self { - buffered: Vec::new(), - found_close: false, - } - } - - fn push(&mut self, chunk: &[u8]) -> Vec { - self.buffered.extend_from_slice(chunk); - - if self.found_close { - return Vec::new(); - } - - if let Some(pos) = find_ascii_case_insensitive(&self.buffered, BODY_CLOSE_PREFIX) { - self.found_close = true; - return self.buffered.drain(..pos).collect(); - } - - let keep_len = BODY_CLOSE_PREFIX.len().saturating_sub(1); - if self.buffered.len() <= keep_len { - return Vec::new(); - } - - let split_at = self.buffered.len() - keep_len; - self.buffered.drain(..split_at).collect() - } - - fn found_close(&self) -> bool { - self.found_close - } - - fn finish(self) -> Vec { - self.buffered - } -} - -fn find_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|window| { - window - .iter() - .zip(needle) - .all(|(left, right)| left.eq_ignore_ascii_case(right)) - }) +struct LateBindingState<'a> { + dispatched: Option, + telemetry: AuctionTelemetryCarry, + price_granularity: PriceGranularity, + ad_bids_state: &'a Arc>>, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + settings: &'a Settings, + tracker: Arc, + fallback: LateBindingFallbackContext<'a>, + decoded_input_bytes: usize, + processed_output_bytes: usize, } -/// Core close-body hold loop. -/// -/// Streams processed output until the first case-insensitive `( +/// Core parser-safe placeholder late-binding loop. +async fn body_close_hold_loop( mut reader: R, writer: &mut W, processor: &mut P, + placeholder: &BidPlaceholder, + tracker: Arc, ctx: AuctionCollectCtx<'_>, + fallback_ctx: LateBindingFallbackContext<'_>, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, - mut telemetry, + telemetry, price_granularity, ad_bids_state, orchestrator, services, settings, } = ctx; + let held_tail_cap = SSAT_HELD_TAIL_CAP_BYTES.min(settings.publisher.max_buffered_body_bytes); + let mut binder = PlaceholderLateBinder::new(placeholder, held_tail_cap); + let mut state = LateBindingState { + dispatched, + telemetry, + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + tracker, + fallback: fallback_ctx, + decoded_input_bytes: 0, + processed_output_bytes: 0, + }; let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; - let mut hold = Some(BodyCloseHoldBuffer::new()); - let mut dispatched = Some(dispatched); loop { - match reader.read(&mut buffer) { - Ok(0) => { - if let Some(hold) = hold.take() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction( - dispatched, - telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, - ) - .await; + match reader.read(&mut buffer) { + Ok(0) => { + let final_out = + processor + .process_chunk(&[], true) + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize processor".to_string(), + }); + let final_out = match final_out { + Ok(final_out) => final_out, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "stream_finalize_error") + .await; + return Err(err); + } + }; + write_late_bound_processed_output(writer, &mut binder, &final_out, &mut state) + .await?; + + let found_placeholder = binder.replaced(); + let remainder = binder.finish(); + write_checked_processed_output_or_abandon( + writer, + &remainder, + &mut state, + "processed_output_error", + ) + .await?; - let held = hold.finish(); - write_processed_chunk( + if !found_placeholder { + if state.tracker.bid_placeholder_inserted() { + log::warn!( + "SSAT bid placeholder was inserted but not observed in processed output; using EOF fallback" + ); + } + resolve_late_bound_bids(&mut state).await; + let tail = build_eof_fallback_tail( + state.tracker.head_injected(), + &state.fallback, + state.ad_bids_state, + ); + write_checked_processed_output_or_abandon( writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - // Signal EOF to lol_html (fires end() which flushes remaining state). - let final_out = processor.process_chunk(&[], true).change_context( - TrustedServerError::Proxy { - message: "Failed to finalize processor".to_string(), - }, - )?; - if !final_out.is_empty() { - writer - .write_all(&final_out) - .change_context(TrustedServerError::Proxy { - message: "Failed to write finalized output".to_string(), - })?; + tail.as_bytes(), + &mut state, + "processed_output_error", + ) + .await?; } break; } Ok(n) => { - if let Some(hold_buffer) = hold.as_mut() { - let ready = hold_buffer.push(&buffer[..n]); - if let Err(err) = write_processed_chunk( - writer, - processor, - &ready, - false, - "Failed to process chunk", - "Failed to write chunk", - ) { - if let Some(dispatched) = dispatched.take() { + state.decoded_input_bytes = state.decoded_input_bytes.saturating_add(n); + if state.decoded_input_bytes > state.settings.publisher.max_buffered_body_bytes { + if let Some(dispatched) = state.dispatched.take() { + emit_abandoned_auction( + state.services, + state.telemetry.observation.take(), + dispatched, + "decoded_input_cap_exceeded", + ) + .await; + } + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher decoded input exceeded maximum streaming size" + .to_string(), + })); + } + + let processed = processor.process_chunk(&buffer[..n], false).change_context( + TrustedServerError::Proxy { + message: "Failed to process chunk".to_string(), + }, + ); + let processed = match processed { + Ok(processed) => processed, + Err(err) => { + if let Some(dispatched) = state.dispatched.take() { emit_abandoned_auction( - services, - telemetry.observation.take(), + state.services, + state.telemetry.observation.take(), dispatched, "stream_process_error", ) @@ -1169,51 +1564,15 @@ async fn body_close_hold_loop( } return Err(err); } - - if hold_buffer.found_close() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction( - dispatched, - telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, - ) - .await; - - let held = hold - .take() - .expect("should have close-body hold buffer") - .finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - } else { - write_processed_chunk( - writer, - processor, - &buffer[..n], - false, - "Failed to process chunk", - "Failed to write chunk", - )?; - } + }; + write_late_bound_processed_output(writer, &mut binder, &processed, &mut state) + .await?; } Err(e) => { - if let Some(dispatched) = dispatched.take() { + if let Some(dispatched) = state.dispatched.take() { emit_abandoned_auction( - services, - telemetry.observation.take(), + state.services, + state.telemetry.observation.take(), dispatched, "stream_read_error", ) @@ -1232,6 +1591,166 @@ async fn body_close_hold_loop( Ok(()) } +async fn write_late_bound_processed_output( + writer: &mut W, + binder: &mut PlaceholderLateBinder, + processed: &[u8], + state: &mut LateBindingState<'_>, +) -> Result<(), Report> { + let scan = match binder.push(processed) { + Ok(scan) => scan, + Err(err) => { + abandon_pending_late_binding_auction(state, "placeholder_scan_error").await; + return Err(err); + } + }; + + match scan { + PlaceholderScan::Emit(bytes) => { + write_checked_processed_output_or_abandon( + writer, + &bytes, + state, + "processed_output_error", + ) + .await + } + PlaceholderScan::Found { before, after } => { + write_checked_processed_output_or_abandon( + writer, + &before, + state, + "processed_output_error", + ) + .await?; + resolve_late_bound_bids(state).await; + let replacement = build_body_close_replacement_tail(state); + write_checked_processed_output(writer, replacement.as_bytes(), state)?; + write_checked_processed_output(writer, &after, state) + } + } +} + +async fn resolve_late_bound_bids(state: &mut LateBindingState<'_>) { + let Some(dispatched) = state.dispatched.take() else { + return; + }; + collect_stream_auction( + dispatched, + state.telemetry.take(), + state.price_granularity, + state.ad_bids_state, + state.orchestrator, + state.services, + state.settings, + ) + .await; +} + +async fn abandon_pending_late_binding_auction( + state: &mut LateBindingState<'_>, + reason: &'static str, +) { + let Some(dispatched) = state.dispatched.take() else { + return; + }; + emit_abandoned_auction( + state.services, + state.telemetry.observation.take(), + dispatched, + reason, + ) + .await; +} + +async fn write_checked_processed_output_or_abandon( + writer: &mut W, + bytes: &[u8], + state: &mut LateBindingState<'_>, + reason: &'static str, +) -> Result<(), Report> { + match write_checked_processed_output(writer, bytes, state) { + Ok(()) => Ok(()), + Err(err) => { + abandon_pending_late_binding_auction(state, reason).await; + Err(err) + } + } +} + +fn current_bids_script(ad_bids_state: &Arc>>) -> String { + ad_bids_state + .lock() + .expect("should lock bid state") + .clone() + .unwrap_or_else(build_empty_bids_script) +} + +fn build_body_close_replacement_tail(state: &LateBindingState<'_>) -> String { + if state.tracker.head_injected() { + return current_bids_script(state.ad_bids_state); + } + + log::info!("SSAT bid injection used missing-head body-close fallback"); + state.tracker.mark_head_injected(); + build_bootstrap_and_bids_tail(&state.fallback, state.ad_bids_state) +} + +fn build_eof_fallback_tail( + head_bootstrap_observed: bool, + fallback_ctx: &LateBindingFallbackContext<'_>, + ad_bids_state: &Arc>>, +) -> String { + if head_bootstrap_observed { + log::info!("SSAT bid injection used EOF fallback after head bootstrap"); + return current_bids_script(ad_bids_state); + } + + log::info!("SSAT bid injection used missing-head EOF fallback"); + build_bootstrap_and_bids_tail(fallback_ctx, ad_bids_state) +} + +fn build_bootstrap_and_bids_tail( + fallback_ctx: &LateBindingFallbackContext<'_>, + ad_bids_state: &Arc>>, +) -> String { + let document_state = crate::integrations::IntegrationDocumentState::default(); + let mut tail = crate::html_processor::build_head_bootstrap_snippet( + fallback_ctx.integration_registry, + fallback_ctx.origin_host, + fallback_ctx.request_host, + fallback_ctx.request_scheme, + &document_state, + fallback_ctx.ad_slots_script, + ); + tail.push_str(¤t_bids_script(ad_bids_state)); + tail +} + +fn write_checked_processed_output( + writer: &mut W, + bytes: &[u8], + state: &mut LateBindingState<'_>, +) -> Result<(), Report> { + if bytes.is_empty() { + return Ok(()); + } + + state.processed_output_bytes = state.processed_output_bytes.saturating_add(bytes.len()); + if state.processed_output_bytes > state.settings.publisher.max_buffered_body_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher processed output exceeded maximum streaming size".to_string(), + })); + } + + writer + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write processed publisher output".to_string(), + })?; + Ok(()) +} + async fn emit_abandoned_auction( services: &RuntimeServices, observation: Option, @@ -1302,35 +1821,6 @@ async fn collect_stream_auction( } } -fn write_processed_chunk( - writer: &mut W, - processor: &mut P, - chunk: &[u8], - is_last: bool, - process_error: &str, - write_error: &str, -) -> Result<(), Report> { - if chunk.is_empty() && !is_last { - return Ok(()); - } - - let out = - processor - .process_chunk(chunk, is_last) - .change_context(TrustedServerError::Proxy { - message: process_error.to_string(), - })?; - if !out.is_empty() { - writer - .write_all(&out) - .change_context(TrustedServerError::Proxy { - message: write_error.to_string(), - })?; - } - - Ok(()) -} - /// Auction dispatch context passed to [`handle_publisher_request`]. pub struct AuctionDispatch<'a> { /// Orchestrator that dispatches and collects SSP bid requests. @@ -2489,8 +2979,7 @@ pub async fn handle_page_bids( #[cfg(test)] mod tests { - use std::io::{self, Read as _, Write as _}; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::io::{Read as _, Write as _}; use brotli::enc::writer::CompressorWriter; use brotli::Decompressor; @@ -2508,47 +2997,6 @@ mod tests { use http::{header, Method, Request as HttpRequest, StatusCode}; use std::sync::Arc; - struct ChunkedReader { - chunks: std::collections::VecDeque>, - read_count: Arc, - } - - impl ChunkedReader { - fn new(chunks: &[&[u8]], read_count: Arc) -> Self { - Self { - chunks: chunks.iter().map(|chunk| chunk.to_vec()).collect(), - read_count, - } - } - } - - impl io::Read for ChunkedReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - let Some(chunk) = self.chunks.pop_front() else { - return Ok(0); - }; - self.read_count.fetch_add(1, Ordering::SeqCst); - let len = chunk.len().min(buf.len()); - buf[..len].copy_from_slice(&chunk[..len]); - Ok(len) - } - } - - struct RecordingProcessor { - read_count: Arc, - body_close_processed_at: Arc, - } - - impl StreamProcessor for RecordingProcessor { - fn process_chunk(&mut self, chunk: &[u8], _is_last: bool) -> Result, io::Error> { - if find_ascii_case_insensitive(chunk, BODY_CLOSE_PREFIX).is_some() { - self.body_close_processed_at - .store(self.read_count.load(Ordering::SeqCst), Ordering::SeqCst); - } - Ok(chunk.to_vec()) - } - } - fn gzip_encode(input: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder @@ -2603,6 +3051,28 @@ mod tests { } } + fn make_html_ssat_params( + settings: &Settings, + dispatched_auction: Option, + ) -> OwnedProcessResponseParams { + OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: settings.publisher.origin_host(), + origin_url: settings.publisher.origin_url.clone(), + request_host: settings.publisher.domain.clone(), + request_scheme: "https".to_owned(), + content_type: "text/html; charset=utf-8".to_owned(), + ad_slots_script: Some( + r#""#.to_owned(), + ), + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction, + price_granularity: Default::default(), + } + } + fn test_auction_request() -> AuctionRequest { AuctionRequest { id: "test-auction".to_string(), @@ -3109,93 +3579,217 @@ mod tests { } #[tokio::test] - async fn body_close_hold_loop_processes_close_tail_before_reading_post_body_chunks() { + async fn ssat_late_binding_ignores_body_text_inside_script() { let settings = create_test_settings(); let services = noop_services(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); let dispatched = DispatchedAuction::empty_for_test(test_auction_request(), 500); - let read_count = Arc::new(AtomicUsize::new(0)); - let body_close_processed_at = Arc::new(AtomicUsize::new(0)); - let reader = ChunkedReader::new( - &[ - b"painted", - b"", - b"", - ], - Arc::clone(&read_count), - ); - let mut processor = RecordingProcessor { - read_count: Arc::clone(&read_count), - body_close_processed_at: Arc::clone(&body_close_processed_at), - }; - let ad_bids_state = Arc::new(Mutex::new(None)); - let ctx = AuctionCollectCtx { - dispatched, - telemetry: AuctionTelemetryCarry { - observation: None, - auction_request: None, - }, - price_granularity: PriceGranularity::default(), - ad_bids_state: &ad_bids_state, - orchestrator: &orchestrator, - services: &services, - settings: &settings, - }; + let mut params = make_html_ssat_params(&settings, Some(dispatched)); let mut output = Vec::new(); + let html = r#"

painted

"#; - body_close_hold_loop(reader, &mut output, &mut processor, ctx) - .await - .expect("should stream body with auction hold"); + stream_publisher_body_async( + EdgeBody::from(html.as_bytes().to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should stream HTML with parser-safe late binding"); - assert_eq!( - body_close_processed_at.load(Ordering::SeqCst), - 1, - "close-body tail should be processed as soon as it is found, before later chunks are read" + let rewritten = String::from_utf8(output).expect("should be utf8"); + assert!( + rewritten.contains("const marker = \"\";"), + "script literal should remain script text" + ); + let script_literal_pos = rewritten + .find("const marker") + .expect("should preserve script literal"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should inject bids script"); + let real_body_close_pos = rewritten.rfind("").expect("should have body close"); + assert!( + bids_pos > script_literal_pos, + "script literal must not trigger early bid insertion" ); - assert_eq!( - std::str::from_utf8(&output).expect("should be utf8"), - "painted", - "post-body chunks should still stream in order" + assert!( + bids_pos < real_body_close_pos, + "bids must be injected before the real parser-confirmed body close" + ); + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak to the client" ); } - #[test] - fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { - let mut hold = BodyCloseHoldBuffer::new(); + #[tokio::test] + async fn ssat_late_binding_appends_bids_at_eof_when_body_close_missing() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"

painted

"; - let ready = hold.push(b"painted"); - let held = hold.finish(); + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should append EOF fallback bids"); - assert_eq!( - std::str::from_utf8(&ready).expect("should be utf8"), - "painted", - "content before should stream before auction collection" + let rewritten = String::from_utf8(output).expect("should be utf8"); + assert!( + rewritten.contains(".bids=JSON.parse"), + "missing body close should append bids at EOF" ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "the close-body tag and trailing bytes should be held" + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on EOF fallback" ); } - #[test] - fn body_close_hold_buffer_holds_close_body_tail_across_chunks() { - let mut hold = BodyCloseHoldBuffer::new(); + #[tokio::test] + async fn ssat_late_binding_prepends_bootstrap_when_body_closes_without_head() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"

painted

"; - let first = hold.push(b"painted"); - let held = hold.finish(); + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should prepend missing-head bootstrap before body-close bids"); + + let rewritten = String::from_utf8(output).expect("should be utf8"); + let slots_pos = rewritten + .find(".adSlots=") + .expect("should inject ad slot state"); + let bundle_pos = rewritten + .find("/static/tsjs=") + .expect("should inject tsjs script tag"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should inject bids script"); + let body_close_pos = rewritten.rfind("").expect("should have body close"); + assert!( + slots_pos < bundle_pos && bundle_pos < bids_pos && bids_pos < body_close_pos, + "missing-head body-close replacement should preserve executable order before " + ); + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on missing-head body-close replacement" + ); + } - let streamed = [first, second].concat(); - assert_eq!( - std::str::from_utf8(&streamed).expect("should be utf8"), - "painted", - "split bytes must not leak before auction collection" + #[tokio::test] + async fn ssat_late_binding_appends_minimal_tail_when_head_and_body_close_missing() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"
fragment without document scaffolding
"; + + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should append missing-head EOF fallback tail"); + + let rewritten = String::from_utf8(output).expect("should be utf8"); + let slots_pos = rewritten + .find(".adSlots=") + .expect("should append ad slot state"); + let bundle_pos = rewritten + .find("/static/tsjs=") + .expect("should append tsjs script tag"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should append bids script"); + assert!( + slots_pos < bundle_pos && bundle_pos < bids_pos, + "fallback tail should preserve executable order" ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "split close-body tag should be held intact" + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on missing-head fallback" + ); + } + + #[tokio::test] + async fn buffered_ssat_postprocessor_path_rejects_processed_output_over_cap() { + let mut settings = create_test_settings(); + settings.publisher.max_buffered_body_bytes = 96; + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href"], + }), + ) + .expect("should update nextjs config"); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + assert!( + registry.has_html_post_processors(), + "nextjs should force the buffered post-processor path" + ); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"x"; + assert!( + html.len() < settings.publisher.max_buffered_body_bytes, + "decoded input should fit so the processed-output cap is exercised" + ); + + let err = stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("should reject processed output over cap"); + + let err = format!("{err:?}"); + assert!( + err.contains("processed output exceeded"), + "error should come from the processed-output cap: {err}" ); } diff --git a/crates/trusted-server-core/src/publisher_late_binding.rs b/crates/trusted-server-core/src/publisher_late_binding.rs new file mode 100644 index 00000000..506279ab --- /dev/null +++ b/crates/trusted-server-core/src/publisher_late_binding.rs @@ -0,0 +1,317 @@ +//! Parser-safe SSAT bid placeholder late binding. +//! +//! The publisher HTML rewriter inserts an opaque placeholder only from a +//! parser-confirmed `` handler. This module scans the rewritten, +//! uncompressed HTML stream for that placeholder and lets the caller replace it +//! with the final bids script without scanning raw origin bytes for HTML syntax. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use error_stack::Report; +use uuid::Uuid; + +use crate::error::TrustedServerError; + +/// Maximum processed-output suffix retained after the bid placeholder is found. +pub(crate) const SSAT_HELD_TAIL_CAP_BYTES: usize = 64 * 1024; + +/// Per-request opaque marker inserted before a parser-confirmed ``. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct BidPlaceholder { + html: String, +} + +impl BidPlaceholder { + /// Generate a new high-entropy HTML comment placeholder for one response. + #[must_use] + pub(crate) fn new() -> Self { + let id = Uuid::new_v4(); + Self { + html: format!(""), + } + } + + /// Return the placeholder as HTML. + #[must_use] + pub(crate) fn as_html(&self) -> &str { + &self.html + } + + /// Return the placeholder bytes used by the streaming scanner. + #[must_use] + pub(crate) fn as_bytes(&self) -> &[u8] { + self.html.as_bytes() + } +} + +/// Tracks which parser insertions actually happened for EOF fallback decisions. +#[derive(Debug, Default)] +pub struct HtmlInjectionTracker { + head_injected: AtomicBool, + bid_placeholder_inserted: AtomicBool, +} + +impl HtmlInjectionTracker { + /// Mark that the normal `` bootstrap insertion ran. + pub(crate) fn mark_head_injected(&self) { + self.head_injected.store(true, Ordering::SeqCst); + } + + /// Mark that the body end-tag placeholder insertion ran. + pub(crate) fn mark_bid_placeholder_inserted(&self) { + self.bid_placeholder_inserted.store(true, Ordering::SeqCst); + } + + /// Return whether the normal `` bootstrap insertion ran. + #[must_use] + pub(crate) fn head_injected(&self) -> bool { + self.head_injected.load(Ordering::SeqCst) + } + + /// Return whether the body end-tag placeholder insertion ran. + #[must_use] + pub(crate) fn bid_placeholder_inserted(&self) -> bool { + self.bid_placeholder_inserted.load(Ordering::SeqCst) + } +} + +/// Result of pushing one processed-output chunk through the placeholder scanner. +#[derive(Debug)] +pub(crate) enum PlaceholderScan { + /// No placeholder was found; emit these bytes immediately. + Emit(Vec), + /// The first placeholder was found. Emit `before`, collect/resolve bids, + /// then emit the replacement followed by `after`. + Found { before: Vec, after: Vec }, +} + +/// Streaming scanner for the bid placeholder. +pub(crate) struct PlaceholderLateBinder { + placeholder: Vec, + buffered: Vec, + replaced: bool, + held_tail_cap: usize, +} + +impl PlaceholderLateBinder { + /// Create a scanner for `placeholder` with a bounded post-placeholder hold. + #[must_use] + pub(crate) fn new(placeholder: &BidPlaceholder, held_tail_cap: usize) -> Self { + Self { + placeholder: placeholder.as_bytes().to_vec(), + buffered: Vec::new(), + replaced: false, + held_tail_cap, + } + } + + /// Return true after the first placeholder has been found. + #[must_use] + pub(crate) fn replaced(&self) -> bool { + self.replaced + } + + /// Push processed uncompressed output through the placeholder scanner. + /// + /// # Errors + /// + /// Returns a proxy error if the suffix held after the first placeholder + /// exceeds the configured held-tail cap. + pub(crate) fn push( + &mut self, + chunk: &[u8], + ) -> Result> { + if self.replaced { + let emit = self.push_after_replacement(chunk); + return Ok(PlaceholderScan::Emit(emit)); + } + + self.buffered.extend_from_slice(chunk); + if let Some(position) = find_bytes(&self.buffered, &self.placeholder) { + self.replaced = true; + let before = self.buffered.drain(..position).collect::>(); + self.buffered.drain(..self.placeholder.len()); + let suffix = std::mem::take(&mut self.buffered); + if suffix.len() > self.held_tail_cap { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "SSAT placeholder held tail {} bytes exceeds {}-byte limit", + suffix.len(), + self.held_tail_cap + ), + })); + } + let after = self.push_after_replacement(&suffix); + return Ok(PlaceholderScan::Found { before, after }); + } + + Ok(PlaceholderScan::Emit(self.drain_safe_prefix())) + } + + /// Finish scanning and return all remaining safe bytes. + #[must_use] + pub(crate) fn finish(mut self) -> Vec { + if self.replaced { + remove_all_placeholders(&mut self.buffered, &self.placeholder); + } + self.buffered + } + + fn push_after_replacement(&mut self, chunk: &[u8]) -> Vec { + self.buffered.extend_from_slice(chunk); + remove_all_placeholders(&mut self.buffered, &self.placeholder); + self.drain_safe_prefix() + } + + fn drain_safe_prefix(&mut self) -> Vec { + let keep_len = partial_placeholder_prefix_len(&self.buffered, &self.placeholder); + if self.buffered.len() <= keep_len { + return Vec::new(); + } + + let split_at = self.buffered.len() - keep_len; + self.buffered.drain(..split_at).collect() + } +} + +fn partial_placeholder_prefix_len(buffered: &[u8], placeholder: &[u8]) -> usize { + let max_len = buffered.len().min(placeholder.len().saturating_sub(1)); + (1..=max_len) + .rev() + .find(|&len| buffered.ends_with(&placeholder[..len])) + .unwrap_or(0) +} + +fn remove_all_placeholders(buffered: &mut Vec, placeholder: &[u8]) { + while let Some(position) = find_bytes(buffered, placeholder) { + buffered.drain(position..position + placeholder.len()); + } +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bid_placeholder_is_unique_html_comment() { + let first = BidPlaceholder::new(); + let second = BidPlaceholder::new(); + + assert_ne!(first, second, "per-request placeholders should be unique"); + assert!( + first.as_html().starts_with(""), + "placeholder should close the HTML comment" + ); + } + + #[test] + fn late_binder_detects_placeholder_split_across_chunks() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let marker = placeholder.as_html(); + let split = marker.len() / 2; + let mut binder = PlaceholderLateBinder::new(&placeholder, SSAT_HELD_TAIL_CAP_BYTES); + + let first = binder + .push(format!("before{}", &marker[..split]).as_bytes()) + .expect("should scan first chunk"); + let second = binder + .push(format!("{}after", &marker[split..]).as_bytes()) + .expect("should scan second chunk"); + + match first { + PlaceholderScan::Emit(bytes) => assert_eq!( + String::from_utf8(bytes).expect("should be utf8"), + "before", + "safe prefix before the split placeholder should stream" + ), + PlaceholderScan::Found { .. } => panic!("should not find split placeholder yet"), + } + let mut output = Vec::new(); + match second { + PlaceholderScan::Found { before, after } => { + assert!(before.is_empty(), "prefix was already emitted"); + output.extend(after); + } + PlaceholderScan::Emit(_) => panic!("should find placeholder in second chunk"), + } + output.extend(binder.finish()); + assert_eq!( + String::from_utf8(output).expect("should be utf8"), + "after", + "suffix after placeholder should be returned by the scanner" + ); + } + + #[test] + fn late_binder_strips_later_placeholder_occurrences() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let mut binder = PlaceholderLateBinder::new(&placeholder, SSAT_HELD_TAIL_CAP_BYTES); + let first = format!("a{}b", placeholder.as_html()); + let second = format!("c{}d", placeholder.as_html()); + + let first = binder + .push(first.as_bytes()) + .expect("should scan first placeholder"); + let second = binder + .push(second.as_bytes()) + .expect("should scan duplicate placeholder"); + let final_bytes = binder.finish(); + + let mut output = Vec::new(); + match first { + PlaceholderScan::Found { before, after } => { + output.extend(before); + output.extend(b"REPLACED"); + output.extend(after); + } + PlaceholderScan::Emit(_) => panic!("should find first placeholder"), + } + match second { + PlaceholderScan::Emit(bytes) => output.extend(bytes), + PlaceholderScan::Found { .. } => panic!("should not find second placeholder"), + } + output.extend(final_bytes); + + let output = String::from_utf8(output).expect("should be utf8"); + assert_eq!(output, "aREPLACEDbcd"); + assert!( + !output.contains(placeholder.as_html()), + "placeholder should not leak after replacement" + ); + } + + #[test] + fn late_binder_rejects_large_held_tail() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let mut binder = PlaceholderLateBinder::new(&placeholder, 4); + let input = format!("{}12345", placeholder.as_html()); + + let err = binder + .push(input.as_bytes()) + .expect_err("held tail should exceed cap"); + + assert!( + format!("{err:?}").contains("held tail"), + "error should mention held tail cap" + ); + } +} diff --git a/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md b/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md new file mode 100644 index 00000000..2e0fda7f --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md @@ -0,0 +1,740 @@ +# SSAT publisher streaming parser-safe implementation plan + +**Source spec:** `docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md` +**Issue:** #857 +**Status:** Draft implementation plan +**Area:** Trusted Server runtime / publisher fallback / server-side ad stack + +## Objective + +Implement the spec in small, reviewable slices so Fastly publisher SSAT HTML can +stream origin bytes through decode/rewrite/bid late-binding/re-encode to the +client without full origin-body or assembled-body materialization, while keeping +all non-Fastly adapters and full-document post-processors explicitly buffered for +this slice. + +The plan preserves the project constraints in `CLAUDE.md`: minimal unrelated +refactoring, no bare workspace `cargo test`, `error-stack` errors, `log` macros, +no Tokio or OS-only dependencies in core runtime code, and target-aware validation. + +## Current code findings + +- `crates/trusted-server-core/src/publisher.rs` + - `handle_publisher_request` always sends publisher origin requests with + `PlatformHttpRequest::new(req, backend_name)`, so Fastly uses the default + buffered platform response path. + - `body_as_reader()` calls `EdgeBody::into_bytes()`, which is incompatible with + true streaming and would materialize `EdgeBody::Stream(_)`. + - `stream_publisher_body_async` uses `BodyCloseHoldBuffer`, which raw-scans + decoded origin bytes for `` containing `''` does not trigger bid collection; + - script/JSON data containing literal or escaped `` split across origin chunks still injects before the close tag; + - missing `` appends the fallback tail at EOF; + - missing `` plus missing `` appends the minimal executable tail; + - multiple body close tags do not inject multiple bid scripts. +- Routing seams: + - Fastly request-level SSAT streaming candidates set `stream_response = true`; + - registries with HTML post-processors do not set `stream_response = true`; + - non-Fastly adapters keep publisher `stream_response = false`. +- Header seams: + - processed publisher HTML strips stale payload headers; + - pass-through/unmodified streams preserve origin validators and range metadata. + +## Phase 1: Core parser-safe late binding + +### 1.1 Add a late-binding module + +Create `crates/trusted-server-core/src/publisher_late_binding.rs` and export it +from `crates/trusted-server-core/src/lib.rs` or keep it `pub(crate)` from +`publisher.rs`. + +Suggested types: + +```rust +pub(crate) const SSAT_HELD_TAIL_CAP_BYTES: usize = 64 * 1024; + +pub(crate) struct BidPlaceholder { + token: String, + html: String, +} + +pub(crate) struct HtmlInjectionTracker { + head_injected: AtomicBool, + placeholder_inserted: AtomicBool, +} + +pub(crate) enum LateBindingOutcome { + BodyClose, + EofFallback, + MissingHeadEofFallback, +} +``` + +Responsibilities: + +- Generate a high-entropy per-request placeholder with `uuid::Uuid::new_v4()`. +- Render the placeholder as a valid HTML comment. +- Expose byte accessors for streaming scans. +- Track whether `` bootstrap and body-close placeholder insertion occurred. +- Define small helper errors as `TrustedServerError::Proxy` contexts rather than + adding broad new error enums unless needed. + +Acceptance tests: + +- placeholders are unique across requests; +- placeholder bytes are valid UTF-8 and valid comment-shaped HTML; +- `LateBindingScanner` can find a placeholder split across arbitrary chunks; +- later occurrences are stripped after first replacement. + +### 1.2 Extend HTML processor configuration + +Modify `crates/trusted-server-core/src/html_processor.rs`. + +Suggested API: + +```rust +pub enum BidInjectionMode { + DirectState, + Placeholder { html: String, tracker: Arc }, +} + +pub enum HtmlPostProcessingMode { + Enabled, + Disabled, +} +``` + +Add fields to `HtmlProcessorConfig`: + +- `bid_injection_mode: BidInjectionMode` defaulting to `DirectState`; +- `post_processing_mode: HtmlPostProcessingMode` defaulting to `Enabled`; +- optional shared `HtmlInjectionTracker`. + +Add builder methods: + +- `with_ad_state(...)` remains for existing call sites; +- `with_bid_placeholder(placeholder, tracker)` for SSAT late binding; +- `without_post_processing()` for the buffered late-binding-before-postprocess + flow and Fastly true streaming guard. + +Refactor the existing head injection code into a reusable helper, for example: + +```rust +pub(crate) fn build_head_bootstrap_snippet( + integrations: &IntegrationRegistry, + ctx: &IntegrationHtmlContext<'_>, + ad_slots_script: Option<&str>, +) -> String +``` + +The helper must preserve current executable ordering: + +1. ad slot state; +2. integration head config inserts; +3. main TSJS bundle; +4. deferred TSJS bundle tags. + +Body end-tag behavior: + +- If no slots matched, keep skipping bid injection entirely. +- In `DirectState`, preserve current behavior for compatibility. +- In `Placeholder`, insert the placeholder in the first parser-confirmed body + end tag only, using the existing once-only guard pattern. +- Set `tracker.placeholder_inserted = true` only when the placeholder is inserted. +- Set `tracker.head_injected = true` when the `` injection actually runs. + +Acceptance tests: + +- `` inside scripts/comments/attributes does not insert the placeholder; +- a real body close inserts one placeholder before the close tag; +- multiple body tags or end tags still produce at most one placeholder; +- direct-state mode remains compatible with existing tests. + +### 1.3 Build the placeholder late binder + +Implement a streaming scanner over processed uncompressed output. + +Suggested shape: + +```rust +pub(crate) struct PlaceholderLateBinder { + placeholder: BidPlaceholder, + overlap: Vec, + replaced: bool, + held_tail_cap: usize, +} + +pub(crate) enum BinderEvent { + Emit(Vec), + PlaceholderFound { prefix: Vec, suffix: Vec }, +} +``` + +The scanner must: + +- keep at most `placeholder.len() - 1` overlap before the placeholder is found; +- emit all safe prefix bytes immediately; +- on first placeholder, return the prefix and suffix around the placeholder; +- hold only the suffix from the current processed chunk while the auction is + collected; +- enforce `min(SSAT_HELD_TAIL_CAP_BYTES, settings.publisher.max_buffered_body_bytes)`; +- after first replacement, strip any subsequent placeholder bytes defensively; +- never forward the placeholder to the client or to post-processors. + +Acceptance tests: + +- placeholder split across chunks is detected; +- bytes before the placeholder are emitted as soon as safe; +- replacement happens exactly once; +- duplicate placeholder occurrences are stripped; +- held-tail cap violation maps to a proxy error. + +### 1.4 Replace `BodyCloseHoldBuffer` + +Modify `crates/trusted-server-core/src/publisher.rs`. + +Replace raw `BodyCloseHoldBuffer` / `find_ascii_case_insensitive` usage with the +placeholder late-binding flow: + +```text +decoded origin bytes + -> HTML processor configured with placeholder mode and no post-processors + -> processed uncompressed output + -> PlaceholderLateBinder + -> auction collect / empty bid script / EOF fallback + -> output cap counter + -> optional recompression + -> writer +``` + +Important behavior: + +- If `params.dispatched_auction` exists, collect it when the first placeholder is + found; otherwise collect at EOF fallback. +- If no auction was dispatched but slots exist, replace the placeholder + immediately with the current or empty bid script. +- Existing `collect_stream_auction`, `write_bids_to_state`, and + `build_bids_script` paths should be reused so output shape and debug comments + remain stable. +- Auction telemetry must complete or abandon on every exit path. +- Delete or deprecate `BodyCloseHoldBuffer` tests once equivalent placeholder + tests cover the behavior. + +### 1.5 Implement EOF fallback tail + +On EOF, after finalizing `lol_html`: + +1. Feed final processor output through the binder. +2. If a placeholder appears in final output, replace it normally. +3. If no placeholder was ever found: + - collect any dispatched auction; + - if `tracker.head_injected` is true, append only the bids script; + - if `tracker.head_injected` is false, append the minimal bootstrap tail in + executable order using the extracted head-snippet helper plus the bids + script. + +The fallback tail is best-effort malformed-document handling; it must still: + +- count against the processed-output cap; +- never expose placeholders; +- preserve current privacy behavior for SSAT HTML. + +## Phase 2: Streaming pipeline, compression, and caps + +### 2.1 Add a non-materializing publisher body pump + +The current synchronous `StreamingPipeline::process` is safe for buffered +`EdgeBody::Once` but not enough for Fastly true streaming because `body_as_reader` +uses `into_bytes()`. + +Add a publisher-specific async body pump in `publisher.rs` or a new core module +such as `publisher_body_stream.rs`. + +Requirements: + +- Consume `EdgeBody::Once` by chunking the bytes without copying the full payload + again. +- Consume `EdgeBody::Stream(_)` with `StreamExt::next().await`. +- Never call `EdgeBody::into_bytes()`, `into_bytes_bounded()`, or equivalent on + the true streaming SSAT path. +- Convert origin stream errors into `TrustedServerError::Proxy` with context. +- Preserve upstream backpressure by pausing reads while auction collection runs. + +Compression implementation options: + +- Prefer explicit incremental decoder/encoder state machines driven by origin + chunks: + - `flate2::Decompress` / `flate2::Compress` for gzip and deflate if practical; + - brotli streaming reader/writer APIs with small bounded buffers. +- If reusing `std::io::Read`-based decoders with a custom stream reader, document + why it does not materialize and test that it consumes one upstream chunk at a + time. Avoid introducing Tokio/OS-only async dependencies into core. + +### 2.2 Preserve compression finalization + +For processed streaming HTML with supported encodings: + +- identity: write processed bytes directly; +- gzip: finalize with `finish()` and propagate errors; +- deflate: finalize with `finish()` and propagate errors; +- brotli: explicitly flush/finalize through the available brotli writer API and + propagate write errors where available. + +The placeholder scanner must run after HTML rewriting and before recompression. + +Acceptance tests: + +- gzip, deflate, and brotli origin HTML decode/rewrite/re-encode successfully; +- decoded client output has bids before the real parser-confirmed ``; +- final compressed response can be decoded by a client; +- finalization errors are propagated or logged/dropped after commit, depending + on where they occur. + +### 2.3 Enforce streaming caps + +Use `settings.publisher.max_buffered_body_bytes` as the effective cumulative +limit for this slice. + +Counters: + +- `decoded_input_bytes`: increment after decompression and before `lol_html`; +- `processed_output_bytes`: increment after late binding and before + recompression; +- `held_tail_bytes`: enforce via `PlaceholderLateBinder`. + +Pre-commit rejection: + +- For identity responses, if `Content-Length` exceeds the limit, reject before + `stream_to_client()`. +- Do not use compressed `Content-Length` as proof of decoded size. + +Mid-stream violation: + +- Return a proxy error from the streaming loop. +- Fastly send path logs and drops the `StreamingBody` without `finish()` because + headers are already committed. +- Buffered paths return a normal error response as they do today. + +Acceptance tests: + +- large identity HTML below cap succeeds; +- decoded input over cap fails/aborts; +- processed output over cap fails/aborts; +- gzip/deflate/br expansion over cap is caught after decode; +- held-tail cap is enforced. + +### 2.4 Normalize transformed response headers + +Add a helper in `publisher.rs`, for example: + +```rust +pub(crate) fn strip_transformed_payload_headers(headers: &mut HeaderMap) +``` + +Remove at minimum: + +- `Content-Length` +- `Content-MD5` +- `Digest` +- `Repr-Digest` +- `Content-Range` +- `Accept-Ranges` +- `ETag` + +Apply this helper to every processed publisher HTML response, buffered or +streaming. Do not apply it to unmodified pass-through streams. + +Keep `Content-Encoding` when the body is re-encoded with the same encoding. +`Transfer-Encoding` remains adapter-owned. + +## Phase 3: Fastly request/response streaming path + +### 3.1 Add request-level streaming candidate options + +Modify `handle_publisher_request` without breaking existing adapters. + +Suggested API: + +```rust +pub struct PublisherRequestOptions { + pub allow_origin_streaming: bool, +} + +pub async fn handle_publisher_request_with_options(..., options: PublisherRequestOptions) +``` + +Keep the current `handle_publisher_request(...)` as a wrapper with +`allow_origin_streaming = false` so Axum/Cloudflare/Spin remain buffered by +default. + +Request-level Fastly candidate gate: + +- method is `GET` and not `HEAD`; +- request is a navigation; +- server-side ad stack may run for the request; +- no full-document HTML post-processors are registered; +- Fastly caller can preserve publisher streaming state out-of-band; +- origin fetch uses `send`, not `send_async`. + +When all gates pass, build the origin request with: + +```rust +PlatformHttpRequest::new(req, backend_name).with_stream_response() +``` + +Tests should assert `StubHttpClient::recorded_stream_response_flags()` for +eligible and ineligible requests. If needed, extend `StubHttpClient` so a queued +response can return `EdgeBody::Stream(_)` when `stream_response` is true. + +### 3.2 Refine response-level route decisions + +The existing `classify_response_route` treats processable non-HTML content as +`Stream`. For this issue, true Fastly SSAT streaming is only for SSAT HTML. + +Add either a new classifier or extend route context: + +```rust +pub(crate) enum PublisherSendRoute { + ProcessedHtmlStream, + StreamUnmodified, + BufferedProcessed, + BufferedUnmodified, + PassThrough, + Bodiless, +} +``` + +Response-level stream eligibility for Fastly: + +- request/response can carry a body (`GET`, not `HEAD`; not `204`, `205`, `304`); +- response is HTML and SSAT assembly is needed; +- content encoding is identity/gzip/deflate/br; +- no post-processors; +- identity `Content-Length` preflight passes; +- processor construction succeeds before client commit. + +If a streaming candidate resolves to non-HTML or unsupported encoding: + +- stream the origin body unmodified when safe; +- abandon any dispatched auction with telemetry; +- preserve bodyless semantics for `HEAD`, `204`, `205`, and `304`. + +If the response needs buffered mode and that requirement was known before fetch +(post-processors), it should never have requested `with_stream_response()`. + +### 3.3 Preserve publisher-streaming state across the Fastly boundary + +Do not put the SSAT streaming state into `http::Extensions`: `DispatchedAuction` +can carry adapter-specific pending request handles and should be treated as +`!Send` / not extension-safe. + +Add a Fastly-specific out-of-band outcome, likely in a new file +`crates/trusted-server-adapter-fastly/src/publisher_streaming.rs`: + +```rust +pub(crate) enum FastlyPublisherFallbackOutcome { + Response(HttpResponse), + Stream(FastlyPublisherStream), +} + +pub(crate) struct FastlyPublisherStream { + response: HttpResponse, + body: EdgeBody, + params: Box, + method: Method, + ec_state: Option, + request_filter_effects: Option, + services: RuntimeServices, + settings: Arc, + registry: IntegrationRegistry, + orchestrator: Arc, +} +``` + +The exact ownership can vary, but the stream outcome must carry everything the +send path needs after headers are finalized: + +- response skeleton; +- origin stream; +- `OwnedProcessResponseParams`; +- method/status for no-body checks; +- settings and registry; +- orchestrator and runtime services; +- EC/request-filter/finalization state. + +### 3.4 Split Fastly publisher fallback dispatch from generic EdgeZero response dispatch + +The existing router returns only `HttpResponse`, which forces buffering. Add a +Fastly-specific publisher fallback dispatcher that can return +`FastlyPublisherFallbackOutcome` before generic `send_edgezero_response` loses +state. + +Recommended approach: + +1. Extract shared publisher fallback setup from `dispatch_fallback` into helper + functions where practical, without refactoring unrelated named routes. +2. In `edgezero_main`, after request conversion and app state construction, + detect the fallback publisher route that is eligible for the Fastly SSAT path. +3. For that path, call the new Fastly publisher dispatcher directly. +4. For all other paths, continue using `app.router().oneshot(core_req)` and + `send_edgezero_response` unchanged. + +The Fastly publisher dispatcher must preserve middleware ordering: + +1. forwarded-header sanitization already happened on the original Fastly request; +2. EC request-state setup; +3. pre-route request filters; +4. asset/named/integration routes must still bypass this path; +5. `handle_publisher_request_with_options(... allow_origin_streaming = true ...)`; +6. buffered fallback for non-stream outcomes; +7. attach or carry EC finalize state and request-filter effects; +8. entry-point finalize headers; +9. EC finalization; +10. request-filter response effects; +11. final set-cookie cache privacy guard. + +If this direct path would duplicate too much router logic, an acceptable +alternative is to make `execute_fallback` return an adapter-private enum and keep +the router for all ordinary responses. The key invariant is that the publisher +stream state reaches Fastly `main.rs` without becoming a plain `EdgeBody::Stream`. + +### 3.5 Add Fastly publisher stream send path + +In `crates/trusted-server-adapter-fastly/src/main.rs`: + +- Add `send_fastly_publisher_stream(FastlyPublisherStream)`. +- Apply request-filter effects to headers before commit. +- Reapply final set-cookie privacy guard before commit. +- Strip transformed payload headers before commit. +- Convert response headers to a Fastly skeleton. +- Call `stream_to_client()`. +- Drive `stream_publisher_body_async(...)` into the Fastly `StreamingBody`. +- Call `finish()` only on success. +- On mid-stream error, log and drop the `StreamingBody` without buffered fallback. +- Run pull-sync-after-send behavior equivalent to the existing EC path once the + response has been sent or attempted. + +Acceptance tests: + +- Fastly SSAT HTML takes the publisher stream path, not asset `stream_asset_body`; +- headers are finalized before streaming starts; +- `Content-Length` and stale validators are absent for processed streaming HTML; +- non-HTML/unsupported-encoding candidate responses stream unmodified and + abandon auction; +- `HEAD`, `204`, `205`, and `304` do not attach processed streaming bodies. + +## Phase 4: Buffered-mode guards, documentation, and adapter parity + +### 4.1 Route post-processors to buffered mode + +Use `IntegrationRegistry::has_html_post_processors()` in the request-level gate +before origin fetch. If true: + +- do not call `with_stream_response()`; +- keep using `buffer_publisher_response_async`; +- run parser-safe late binding before post-processors; +- ensure no placeholder is visible to post-processors or clients. + +Implementation detail: + +- Configure `lol_html` with placeholder mode and post-processing disabled. +- Run late binding into the bounded buffer. +- Then run registered post-processors over the full final HTML. + +This may require extracting post-processing from `HtmlWithPostProcessing` into a +reusable helper, while keeping existing public behavior unchanged for other +callers. + +### 4.2 Keep non-Fastly adapters explicitly buffered + +Update comments and tests in: + +- `crates/trusted-server-adapter-axum/src/app.rs` +- `crates/trusted-server-adapter-cloudflare/src/app.rs` +- `crates/trusted-server-adapter-spin/src/app.rs` + +Expected behavior: + +- they call the default non-streaming publisher handler; +- they call `buffer_publisher_response_async`; +- they benefit from parser-safe late binding and EOF fallback; +- they enforce the existing buffered cap; +- they are not described as true streaming. + +### 4.3 Preserve privacy/cache behavior + +Regression tests should prove SSAT HTML still gets: + +```http +Cache-Control: private, max-age=0 +``` + +and strips shared/runtime edge-cache headers: + +- `Surrogate-Control` +- `Fastly-Surrogate-Control` +- `CDN-Cache-Control` +- `Cloudflare-CDN-Cache-Control` + +The final set-cookie cache privacy guard in Fastly must still run after EC +finalization and request-filter effects. + +### 4.4 Update docs + +Candidate docs to update after implementation behavior exists: + +- Fastly runtime / architecture docs: Fastly SSAT publisher HTML is true + streaming only on the guarded path. +- Adapter docs: Axum, Cloudflare, and Spin publisher SSAT are buffered for this + slice. +- Next.js integration docs: full-document post-processing remains buffered. + +Run docs formatting if docs are edited: + +```bash +cd docs && npm run format +``` + +## Phase 5: Verification plan + +Run focused validation after each phase, then the full target-matched gate before +handoff. + +Minimum Rust verification: + +```bash +cargo fmt --all -- --check +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +If JS/TS is touched unexpectedly: + +```bash +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && npm run format +``` + +If docs are touched: + +```bash +cd docs && npm run format +``` + +Do **not** use bare `cargo test --workspace`; it compiles Fastly for the wrong +target in this workspace. + +## Acceptance criteria checklist + +- [ ] Fastly request-level SSAT candidates request origin streaming with + `with_stream_response()`. +- [ ] Fastly processed SSAT HTML writes to `fastly::StreamingBody` without first + materializing the origin body or final assembled body. +- [ ] Publisher SSAT streaming state is not mistaken for asset pass-through + `EdgeBody::Stream(_)`. +- [ ] ``. +- [ ] Missing `` appends bids or minimal SSAT fallback tail at EOF. +- [ ] gzip, deflate, and brotli stream through decode/rewrite/re-encode. +- [ ] Decoded-input, processed-output, and held-tail caps are enforced without a + full-document allocation. +- [ ] Full-document post-processors route to buffered mode and never see a raw + placeholder. +- [ ] Axum, Cloudflare, and Spin remain documented/tested buffered mode. +- [ ] SSAT HTML retains `Cache-Control: private, max-age=0` and no shared edge + cache headers. +- [ ] Processed HTML strips stale payload validators/range metadata; unmodified + pass-through preserves them. +- [ ] Every dispatched auction is collected or abandoned on all exit paths. + +## Risks and open questions + +- **Fastly fallback parity risk:** a direct Fastly publisher streaming dispatcher + can accidentally bypass auth, request filters, EC finalization, pull sync, or + cache privacy guards. Keep the direct path narrow and share helpers with the + existing fallback path where possible. +- **Streaming decoder complexity:** current compression helpers are `Read`/`Write` + based. The true stream path needs chunk-driven processing without + `into_bytes()`. Prefer a small publisher-specific pump and extensive tests over + broad refactors to `StreamingPipeline`. +- **Post-processor ordering:** Next.js/full-document post-processing must see + final HTML with bids, not placeholders. This likely requires extracting + post-processing into an explicit buffered step. +- **Mid-stream failures:** once Fastly commits headers, cap/decode/processor/write + errors can only truncate the response. Tests and logs should reflect that; do + not attempt buffered fallback after commit. +- **Type ownership:** avoid storing `DispatchedAuction` or other adapter-specific + pending handles in `http::Extensions`; use an adapter-private outcome that can + carry non-`Send` state directly. +- **Scope control:** do not make Next.js post-processing streaming-safe and do not + add true streaming for Axum/Cloudflare/Spin in this issue. diff --git a/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md b/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md new file mode 100644 index 00000000..0744909f --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md @@ -0,0 +1,823 @@ +# Make SSAT publisher HTML assembly truly streaming and parser-safe + +**Issue:** #857 +**Status:** Draft +**Area:** Trusted Server runtime / publisher fallback / server-side ad stack + +## Summary + +Trusted Server's server-side ad stack (SSAT) publisher HTML path should be a +true streaming assembly path on Fastly: origin HTML bytes should flow through +Trusted Server and reach the browser before the origin response is fully read, +while bid injection remains parser-safe and privacy-preserving. + +Today the code has a streaming-shaped publisher API, but the Fastly origin +response can still be materialized into a single WASM-heap allocation before +client bytes flow, and the EdgeZero publisher fallback buffers the assembled +publisher response before sending it. The current SSAT bid hold also scans raw +origin bytes for `` when one is + present. +- If no parser-confirmed body close exists, append the SSAT fallback tail at EOF + as a best-effort fallback. +- Streaming mode supports gzip, deflate, and brotli origin HTML by decoding, + rewriting, and re-encoding incrementally. +- Streaming mode enforces cumulative decoded-input and processed-output caps + without full-document allocation. +- Full-document HTML post-processors, including the current Next.js + post-processor, are explicitly routed through buffered mode for this slice. +- Axum, Cloudflare, and Spin publisher SSAT behavior is documented and tested as + buffered mode for this slice. +- Existing SSAT privacy behavior remains unchanged: + `Cache-Control: private, max-age=0` and no shared/runtime edge-cache headers + on assembled per-user HTML. +- Processed HTML responses do not retain stale payload validators or range + metadata from the origin representation. + +## Non-goals + +- Origin-template caching. +- Transformed-template caching. +- Dynamic HTML/RSC/API cache-key design. +- SSAT compression offload via `Accept-Encoding: identity` / `X-Compress-Hint`. +- Akamai-specific cache behavior. +- Auction backend timeout/name fixes. +- Making Next.js RSC post-processing streaming-safe in this slice. +- Making Axum, Cloudflare, or Spin publisher SSAT truly streaming in this slice. + +## Current behavior + +### Publisher response shape + +`crates/trusted-server-core/src/publisher.rs` exposes: + +- `PublisherResponse::Buffered` +- `PublisherResponse::Stream` +- `PublisherResponse::PassThrough` + +`PublisherResponse::Stream` currently means "processable body with headers +separated from the body", not necessarily client-visible streaming. Its docs +already note that, on the interim path, the body may have been materialized in +WASM heap upstream. + +Also, the current Fastly send boundary treats a plain `EdgeBody::Stream(_)` as +an asset pass-through stream. It does not carry the publisher-specific state +needed to run the SSAT HTML assembly loop, collect/abandon an auction, or apply +parser-safe bid late binding. True publisher streaming therefore needs an +explicit publisher-streaming envelope or equivalent dispatch path, not just a +processed response whose body happens to be `EdgeBody::Stream(_)`. + +### Fastly origin body materialization + +`handle_publisher_request` sends the origin request with: + +```rust +services + .http_client() + .send(PlatformHttpRequest::new(req, backend_name)) + .await +``` + +On Fastly, `PlatformHttpRequest::stream_response` defaults to `false`, so +`crates/trusted-server-adapter-fastly/src/platform.rs` converts the +`fastly::Response` body through `take_body_bytes()` before returning the +platform-neutral response. That can allocate the full origin response body before +any client bytes are sent. + +### EdgeZero publisher fallback buffering + +`crates/trusted-server-adapter-fastly/src/app.rs` currently resolves publisher +fallback responses with `buffer_publisher_response_async`, producing a single +`Body::Once` response. `crates/trusted-server-adapter-fastly/src/main.rs` only +streams `EdgeBody::Stream(_)` bodies directly to Fastly's `StreamingBody`; SSAT +publisher HTML is therefore sent as a completed buffered response. + +### Raw close-body hold + +`stream_publisher_body_async` uses `BodyCloseHoldBuffer`, which searches decoded +origin bytes for the case-insensitive raw prefix ` + const marker = '' + +``` + +The actual injection is parser-aware because it happens in a `lol_html` body +end-tag handler, but the decision about when to stop streaming and collect the +auction is not parser-aware. + +### Full-document HTML post-processors + +`crates/trusted-server-core/src/html_processor.rs` wraps the HTML rewriter in +`HtmlWithPostProcessing`. When any `IntegrationHtmlPostProcessor` is registered, +it accumulates the rewritten document and runs post-processors only at EOF. The +current Next.js integration registers such a post-processor. + +This is intentionally not stream-safe today and must be treated as buffered mode +for this issue. + +## Locked decisions + +1. **Missing `` fallback:** append the SSAT fallback tail at EOF when no + parser-confirmed body close is available. +2. **Post-processors:** full-document HTML post-processors are an acceptable + buffered-mode tradeoff for this slice. Next.js streaming-safe post-processing + is deferred. +3. **Compression:** true streaming should support gzip, deflate, and brotli by + incrementally decoding, rewriting, and re-encoding. +4. **Adapter scope:** Fastly is the first true-streaming target. Axum, + Cloudflare, and Spin are documented/tested as buffered mode. +5. **Caps:** streaming mode enforces cumulative decoded-input and + processed-output caps, plus a small held-tail cap. + +## Architecture + +### High-level Fastly SSAT streaming flow + +```text +Client request + -> Fastly entry point + -> publisher fallback route + -> dispatch SSAT auction requests + -> fetch publisher origin with streaming response body for request-level SSAT candidates + -> inspect origin response metadata and choose the response-level route + -> if stream-eligible, finalize response headers before client commit + -> Fastly response.stream_to_client() + -> incremental origin body assembly: + encoded origin bytes + -> decoder, if Content-Encoding is gzip/deflate/br + -> cumulative decoded-input cap + -> lol_html processor + -> parser-inserted bid placeholder detection + -> auction collect at placeholder, or EOF fallback + -> cumulative processed-output cap + -> encoder, if response remains compressed + -> Fastly StreamingBody + -> finish StreamingBody +``` + +### Buffered-mode flow + +Buffered mode remains the compatibility path for: + +- non-Fastly adapters in this slice; +- Fastly SSAT HTML when full-document HTML post-processors are registered; +- request shapes that are known before fetch not to be safe streaming candidates; +- unsupported response shapes where the stream cannot safely commit headers; +- existing unmodified buffered routes. + +Buffered mode may still use the same parser-safe placeholder late-binding logic, +but its output sink is a bounded in-memory writer rather than Fastly's +`StreamingBody`. + +## Parser-safe bid late binding + +### Problem with raw-byte scanning + +Raw ``; +- JSON data inside a script block; +- text in a comment; +- attribute content; +- malformed markup that `lol_html` does not interpret as a body end tag. + +Therefore raw scanning is not a safe trigger for auction collection or bid-tail +holding. + +### Proposed mechanism + +Use `lol_html` as the only authority for detecting the real body end tag. + +1. For SSAT requests, configure the HTML processor with a per-request opaque bid + placeholder token instead of directly injecting bids from shared state in the + `body` end-tag handler. +2. The body end-tag handler inserts that placeholder immediately before the real + parser-confirmed ``. +3. The publisher streaming loop scans **processed uncompressed output**, not raw + origin input, for the opaque placeholder. +4. Until the placeholder is found, processed output streams through immediately, + subject only to the placeholder scanner's overlap buffer. +5. When the placeholder is found: + - emit bytes before the placeholder; + - stop reading additional origin bytes after the current decoded/processed + chunk has been handled; + - hold the placeholder plus any suffix from the current processed chunk; + - collect the dispatched auction; + - build the bids script; + - replace the placeholder with the bids script; + - emit the held suffix; + - resume reading and streaming origin bytes. +6. At EOF, if no placeholder was found: + - collect the dispatched auction if it has not already been collected; + - finalize the HTML processor; + - if final processor output contains the placeholder, replace it normally; + - otherwise append the SSAT fallback tail at EOF. + +Pausing origin reads while the auction is collected is intentional. The +implementation should rely on the runtime's normal upstream backpressure rather +than draining the rest of the origin response into memory. The wait remains +bounded by the existing auction collection timeout/deadline; if collection fails +or times out, replace the placeholder with the empty/current bids script and +continue streaming. + +The EOF fallback covers both documents that have a `` without a parsed end +tag and malformed documents that never expose a body end tag. If the normal +`` injection has already run, the fallback tail may be just the bids +script. If no head injection ran, the fallback tail must include the minimal SSAT +bootstrap in executable order — ad slot state, integration head config required +by the TSJS bundle, the TSJS script tag(s), and then the bids script. This is a +best-effort malformed-document path; it must still be bounded by the processed +output cap and must not leak placeholders. + +The placeholder should be a per-request high-entropy token, such as an HTML +comment containing a UUID, for example: + +```html + +``` + +The exact format is an implementation detail, but it must be: + +- generated per request; +- impossible for normal origin content to predict; +- valid HTML when inserted by `lol_html`; +- scanned only in processed uncompressed output; +- fully removed from the client response on success. + +### Placeholder scanner requirements + +The placeholder scanner must be streaming-safe: + +- handle the placeholder split across processor output chunks; +- emit all bytes before the placeholder as soon as safely possible; +- keep at most `placeholder.len() - 1` bytes of overlap while searching; +- once the placeholder is found, hold only the placeholder and suffix bytes until + auction collection completes; +- enforce a held-tail cap so the hold cannot become accidental full-document + buffering; +- after the first successful replacement, strip any later occurrence of the same + placeholder token from processed output rather than forwarding it to the + client. + +### Bid source behavior + +If an auction was dispatched, the first parser-confirmed placeholder triggers +auction collection. The resulting winning bids are written through the existing +bid-script builder path. + +If no auction was dispatched but the SSAT ad stack still needs to inject slot +state and an empty bids script, the late-binding layer should replace the +placeholder immediately with the empty/current bids script without awaiting an +auction. + +If multiple body end tags exist, only the first placeholder is replaced with +bids. The preferred implementation is for the `lol_html` body end-tag handler to +insert at most one placeholder, using the existing once-only guard pattern. The +late-binding scanner must still defensively remove any later placeholders, if +any, so no raw placeholder leaks to the client and bids are not injected multiple +times. + +## Compression design + +Streaming SSAT HTML must support the currently supported origin content +encodings: + +- identity / no `Content-Encoding`; +- `gzip`; +- `deflate`; +- `br`. + +The streaming path should preserve the existing response encoding unless a +separate route explicitly strips it. Compression offload and forcing identity +origin fetches belong to #858 and are out of scope here. + +### Pipeline placement + +Parser-safe placeholder detection must occur after HTML rewriting and before +recompression: + +```text +encoded origin chunk + -> decoder + -> decoded HTML bytes + -> lol_html processor + -> processed uncompressed bytes containing parser placeholder + -> placeholder late binder / bid insertion + -> encoder + -> client writer +``` + +Scanning compressed output would be incorrect because the placeholder would not +be visible. Scanning raw decoded input would reintroduce the original parser +context bug. + +The true-streaming implementation must drive this pipeline from origin stream +chunks. It must not convert the publisher body through `EdgeBody::into_bytes()`, +`take_body_bytes()`, or any equivalent full-body materialization before decoding +and rewriting. Buffered adapters may continue to use the bounded in-memory path. + +### Decoder/encoder finalization + +The existing synchronous pipeline already explicitly finalizes gzip and deflate +encoders with `finish()` and uses explicit brotli flush/finalization behavior. +The new SSAT streaming loop must preserve equivalent finalization semantics: + +- gzip: call `finish()` and propagate errors; +- deflate: call `finish()` and propagate errors; +- brotli: explicitly flush/finalize through the available brotli writer API and + propagate write errors where the API exposes them. + +If compression finalization fails after headers have been committed, log the +error and abort the streaming body. + +## Streaming caps + +True streaming removes the full-document allocation, but it must still protect +WASM memory and CPU from unbounded input/output growth. + +### Cap types + +Use the existing publisher body limit initially: + +```text +settings.publisher.max_buffered_body_bytes +``` + +Despite the current name, it should be applied to streaming SSAT as the +cumulative safety limit until/unless a separate streaming-specific setting is +introduced. + +Enforce: + +1. **Decoded input cap** + - Count bytes after decompression and before HTML parsing. + - Protects against compressed expansion bombs and excessive parser workload. +2. **Processed uncompressed output cap** + - Count bytes emitted by the HTML processor and late binder before + recompression. + - Protects against output amplification from rewrites and injected scripts. +3. **Held-tail cap** + - Count bytes retained after the parser placeholder is found and before the + auction is collected. + - Use a concrete default so tests and operators can reason about behavior: + `SSAT_HELD_TAIL_CAP_BYTES = 64 * 1024` (8× the current 8 KiB stream chunk), + with the effective cap clamped to `settings.publisher.max_buffered_body_bytes` + when that setting is smaller. + - If exceeded, treat it as a streaming safety violation. + +### Pre-commit rejection vs mid-stream abort + +When headers have not yet been committed, known oversized responses should fail +cleanly with an error response. For example, an identity response with +`Content-Length` greater than the configured cap can be rejected before +`stream_to_client()`. Compressed `Content-Length` is not a decoded-size signal and +must not be used as proof that the decoded input is under the cap; compressed +responses are checked by the cumulative decoded-input counter while streaming. + +For compressed, chunked, or unknown-size responses, the true size may only be +known after streaming begins. If a cumulative cap is exceeded after headers are +committed: + +- log the violation with enough context for diagnosis; +- abort/drop the streaming body; +- do not attempt to recover with a buffered fallback because the client-visible + response has already started. + +This matches standard reverse-proxy behavior for mid-stream processing failures. + +## Fastly adapter design + +### Origin fetch + +Fastly has to make the platform request before it knows the origin response's +`Content-Type`, status, or `Content-Encoding`, so the route decision is +intentionally two-phase: + +1. **Request-level candidate decision, before origin fetch.** Use a streaming + platform response only when request metadata and configuration make true SSAT + streaming possible: the method can carry a response body (`GET`, not `HEAD`), + the server-side ad stack may run for this navigation, no full-document HTML + post-processors are registered, and the Fastly send boundary can preserve the + publisher-streaming state through response finalization. +2. **Response-level route decision, after origin headers arrive and before + client commit.** Inspect status, `Content-Type`, `Content-Encoding`, and + relevant headers to choose processed streaming, streamed unmodified + pass-through, bodiless response handling, or a pre-commit error. + +Request-level Fastly candidates should use the existing platform flag shape: + +```rust +PlatformHttpRequest::new(req, backend_name).with_stream_response() +``` + +On Fastly, this preserves the origin response body as `EdgeBody::Stream(_)` +instead of using `take_body_bytes()`. + +The platform-level body materialization cap still applies to non-streaming +requests. Streaming SSAT applies its own cumulative decoded/processed caps in +core. Because a candidate request may later prove not to be processable SSAT +HTML, the Fastly path must be able to forward a preserved origin stream +unmodified without first buffering it. If a request is known before fetch to +require buffered mode, such as post-processor-enabled HTML, do not request a +streaming origin body. + +### Client commit + +For true-streaming Fastly SSAT responses: + +1. Build and mutate response headers in core as today. +2. Preserve the publisher streaming state across the fallback/entry-point + boundary. Do not encode a processed publisher stream as a plain + `EdgeBody::Stream(_)` unless the send path can distinguish it from the asset + pass-through stream case. A dedicated `PublisherResponse` variant, + response extension, or Fastly-specific fallback result is acceptable as long + as it carries the response skeleton, origin stream, processing params, + orchestrator/services access, and auction telemetry token. +3. Apply all finalization that must happen before client commit, including: + - EC/privacy finalization; + - request-filter response effects; + - final cache/privacy guards; + - transformed-response header normalization; + - removal of `Content-Length` for processed streaming bodies. +4. Convert the response headers to a Fastly skeleton response. +5. Call `stream_to_client()`. +6. Run the SSAT streaming body assembly loop, writing to the Fastly + `StreamingBody`. +7. Call `finish()` on success. +8. On any mid-stream error, log and drop the streaming body. + +Once `stream_to_client()` is called, no response header mutation is possible. +The Fastly entry point therefore owns the boundary between final response header +mutation and body streaming. + +### Route decision + +A Fastly publisher response is response-level stream-eligible when all of the +following are true: + +- the request/response can carry a body (`GET`, not `HEAD`, not `204`, `205`, or + `304`); +- the response is HTML that needs SSAT assembly, not merely a text/JS/CSS/JSON + response that the generic publisher processor could rewrite; +- the content encoding is supported (`identity`, `gzip`, `deflate`, `br`); +- no full-document HTML post-processors are registered for the active + integration registry; +- pre-commit header checks, including identity `Content-Length` preflight, have + not rejected the response; +- response headers can be finalized before commit; +- processor construction succeeds before commit. + +If the request was fetched as a streaming candidate but the response is not +processable SSAT HTML, Fastly should stream the origin body unmodified when that +is safe, abandon any dispatched auction with telemetry, and preserve bodyless +status semantics by not attaching a body for `HEAD`, `204`, `205`, or `304`. +If the response requires buffered mode and that requirement was known before the +origin fetch, the request should not have used `with_stream_response()`. + +## Non-Fastly adapters + +For this issue, Axum, Cloudflare, and Spin remain buffered for publisher SSAT +HTML. Their behavior must be explicit in docs and tests: + +- they may still call `buffer_publisher_response_async`; +- they must preserve parser-safe bid injection semantics; +- they must enforce the existing buffered body cap; +- they should remove or normalize headers such as `Transfer-Encoding` where the + adapter already does so after buffering; +- they do not satisfy the Fastly true-streaming acceptance criterion and should + not be described as true streaming in docs or comments. + +Future work can add adapter-specific streaming support once each runtime has a +safe response-commit and body-streaming boundary. + +## HTML post-processors + +Any registered `IntegrationHtmlPostProcessor` means the HTML path requires the +full rewritten document. For this slice: + +- Fastly SSAT HTML with post-processors must route to buffered mode. +- Next.js is explicitly accepted as buffered mode. +- The request-level Fastly route decision should use + `IntegrationRegistry::has_html_post_processors()` or an equivalent presence + check before requesting a streaming origin body. +- Tests should verify that a registry with a post-processor does not enter the + true Fastly streaming path and does not call `with_stream_response()` for the + publisher origin fetch. + +Buffered post-processor mode must preserve the current observable ordering: +post-processors should see the rewritten HTML with the final bids script, not a +raw placeholder. The bounded buffered pipeline should therefore be: + +```text +decode origin body + -> lol_html rewrite with parser placeholder + -> parser-safe late binding / bids replacement or EOF fallback + -> full-document post-processors + -> encode or buffer final body +``` + +No placeholder token may be visible to post-processors unless the post-processor +API explicitly opts into that in future work, and no placeholder may leak to the +client. + +This does not prevent script rewriters or attribute rewriters from streaming; +those run inside `lol_html` and are distinct from full-document post-processors. + +## Privacy and cache behavior + +SSAT-assembled HTML can contain per-user slot state and bid data. This issue +must preserve the existing privacy contract: + +```http +Cache-Control: private, max-age=0 +``` + +and strip runtime/shared edge-cache headers, including: + +```http +Surrogate-Control +Fastly-Surrogate-Control +CDN-Cache-Control +Cloudflare-CDN-Cache-Control +``` + +This applies before Fastly commits the streaming response. Streaming must not +weaken the final cache/privacy guard that protects responses with `Set-Cookie` +or per-user SSAT data. + +### Transformed-response header normalization + +Any processed HTML response has a different byte representation from the origin +response, even when `Content-Encoding` is preserved through decode/re-encode. +Before client commit, the streaming and buffered processed paths should remove +payload-derived headers unless they are recomputed for the transformed payload. +At minimum, processed publisher HTML should remove: + +```http +Content-Length +Content-MD5 +Digest +Repr-Digest +Content-Range +Accept-Ranges +ETag +``` + +`Content-Encoding` should be preserved when the response is re-encoded with the +same encoding. `Transfer-Encoding` remains adapter-owned and should continue to +be removed or normalized where the adapter already does so. Unmodified +pass-through streams keep origin payload validators and range metadata. + +## Error handling + +### Before client commit + +Errors before `stream_to_client()` should return a normal error response where +possible: + +- invalid origin configuration; +- backend registration failure; +- origin fetch failure before headers; +- unsupported route discovered before commit that cannot be safely streamed + unmodified; +- processor construction failure; +- known oversized identity body from `Content-Length` preflight. + +A streaming candidate that resolves to non-HTML, unsupported-encoding HTML, or a +bodiless status is not automatically an error: it may be streamed or returned +unmodified when headers can still be finalized safely. If an SSAT auction was +dispatched and the response cannot be processed, consume or abandon the dispatch +token explicitly so telemetry remains accurate. + +### After client commit + +Errors after headers are committed cannot be converted into a clean HTTP error +response. The streaming path should: + +- log the error; +- emit abandonment/completion telemetry where applicable; +- drop/abort the streaming body without calling `finish()`; +- let the client observe a truncated response. + +Mid-stream errors include: + +- origin stream read failure; +- decompression failure; +- HTML processor failure; +- placeholder hold cap exceeded; +- decoded-input or processed-output cap exceeded; +- write failure to the client streaming body; +- compression finalization failure. + +## Observability + +Add low-cardinality logs or telemetry fields sufficient to understand streaming +behavior without exposing user data: + +- route mode: `fastly_streaming`, `streamed_unmodified_non_html`, + `streamed_unmodified_unsupported_encoding`, `buffered_post_processor`, + `buffered_adapter`, `pass_through`, `buffered_unmodified`; +- request-level candidate decision and response-level route decision; +- whether bid insertion used `body_close`, `eof_fallback`, or + `missing_head_eof_fallback`; +- decoded input bytes; +- processed output bytes; +- held-tail bytes; +- whether the response was compressed and which encoding was used; +- auction collect wait duration at the body-close hold; +- cap violation reason, if any. + +Do not log bid payloads, EC IDs, cookies, consent strings, or full URLs with +sensitive query parameters. + +## Testing strategy + +### Core parser-safety tests + +- Inline script contains `""`; auction collection is not triggered until + the real parser-confirmed body close. +- JSON/script data contains escaped or literal `` split across origin chunks still produces a parser placeholder + and bid injection before the close tag. +- Placeholder token split across processor output chunks is detected and + replaced exactly once. +- Multiple body close tags do not inject bids multiple times and do not leak + placeholders. +- Missing `` appends bids or the SSAT fallback tail at EOF. +- Missing `` plus missing `` appends the minimal SSAT fallback tail + at EOF without leaking placeholders. +- Normal bid injection still places bids before `` when the close tag is + present. + +### Streaming cap tests + +- Large identity HTML below the cap streams successfully. +- Decoded input exceeding the cap fails/aborts without allocating the full body. +- Processed output exceeding the cap fails/aborts. +- Highly compressible gzip/deflate/br HTML that expands over the decoded cap is + rejected during streaming. +- Held-tail cap violation aborts rather than growing an unbounded hold buffer. +- A streaming-body test proves the true Fastly path consumes chunks incrementally + and does not call `EdgeBody::into_bytes()` or an equivalent full-body materializer. + +### Compression tests + +For gzip, deflate, and brotli: + +- compressed origin HTML streams through decode/rewrite/re-encode; +- decompressed output contains injected bids at the correct location; +- final compressed response can be decoded by a client; +- encoder finalization errors, where testable, are propagated as stream errors. + +### Fastly adapter tests + +- Fastly SSAT stream-eligible HTML preserves the origin body as + `EdgeBody::Stream(_)` before client send. +- Fastly SSAT stream-eligible HTML does not call the buffered publisher response + resolver. +- Fastly streaming uses an explicit publisher-streaming dispatch path and is not + mistaken for an asset pass-through `EdgeBody::Stream(_)`. +- Fastly streaming removes `Content-Length` and other payload-derived headers + for processed bodies. +- Fastly response headers are finalized before the streaming body is opened. +- Fastly post-processor-enabled HTML routes to buffered mode and does not request + `with_stream_response()` from the publisher origin. +- Fastly streaming candidates that resolve to non-HTML or unsupported-encoding + HTML stream unmodified safely and abandon any dispatched auction. +- Fastly `HEAD`, 204, 205, and 304 responses do not attach a streaming processed + body. + +### Non-Fastly adapter tests + +- Axum publisher SSAT is explicitly buffered in this slice. +- Cloudflare publisher SSAT is explicitly buffered in this slice. +- Spin publisher SSAT is explicitly buffered in this slice. +- Buffered non-Fastly paths preserve parser-safe bid injection and EOF fallback. +- Buffered post-processor paths run parser-safe late binding before + post-processors and never expose placeholders to post-processors or clients. + +### Privacy regression tests + +- SSAT HTML still emits `Cache-Control: private, max-age=0`. +- Shared/runtime cache headers are stripped from SSAT HTML. +- Streaming Fastly SSAT responses with cookies or per-user data do not regain + shared cacheability after finalization. +- Processed streaming HTML strips stale entity validators/range headers while + unmodified pass-through streams preserve them. + +## Implementation phases + +### Phase 1: Parser-safe late-binding in core + +- Add per-request bid placeholder support to the HTML processor configuration. +- Add a streaming placeholder late-binder that scans processed uncompressed + output and replaces the parser-inserted placeholder with bids. +- Replace raw `BodyCloseHoldBuffer` usage for SSAT collection with + placeholder-triggered collection. +- Add EOF fallback bid append, including the missing-head minimal SSAT fallback + tail when normal head injection never ran. +- Keep existing buffered adapters working through the new parser-safe path. +- Ensure buffered post-processor mode performs late binding before post-processing + so post-processors see final HTML rather than raw placeholders. + +### Phase 2: Streaming caps + +- Add decoded-input and processed-output cumulative counters to the SSAT + streaming loop. +- Add the concrete 64 KiB held-tail cap, clamped by the publisher body limit. +- Map cap violations to the existing proxy error type. +- Add tests for identity and compressed expansion cases. + +### Phase 3: Fastly origin streaming and client streaming + +- Add the two-phase Fastly route decision: request-level streaming candidates + before fetch and response-level stream eligibility after origin headers. +- Request streaming origin responses only for Fastly request-level SSAT + candidates. +- Preserve `EdgeBody::Stream(_)` through an explicit publisher-streaming dispatch + path rather than the asset pass-through stream path. +- Add a Fastly entry-point path that finalizes headers, opens + `stream_to_client()`, and drives the SSAT streaming loop into the + `StreamingBody`. +- Stream non-HTML or unsupported-encoding candidate responses unmodified when + safe, with auction abandonment telemetry. +- Ensure dispatched auctions are collected or abandoned on every exit path. + +### Phase 4: Buffered-mode documentation and route guards + +- Route full-document post-processor HTML to buffered mode before requesting a + streaming origin response. +- Normalize transformed-response headers on both buffered and streaming + processed HTML paths. +- Document Next.js as buffered mode for this slice. +- Document Axum, Cloudflare, and Spin publisher SSAT as buffered mode. +- Add adapter tests so future changes do not accidentally claim streaming parity. + +### Phase 5: Verification + +Minimum targeted verification for touched Rust code: + +```bash +cargo fmt --all -- --check +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +If implementation touches JS/TS, also run: + +```bash +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && npm run format +``` + +## Acceptance criteria mapping + +| Issue acceptance criterion | Design coverage | +| --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly SSAT HTML path no longer requires full origin body materialization before sending client bytes. | Two-phase Fastly route decision uses streaming origin bodies for request-level candidates, preserves publisher-streaming state explicitly, avoids `into_bytes()`/`take_body_bytes()`, and writes to `StreamingBody`. | +| Streaming path enforces a cumulative body cap without requiring a single full-body allocation. | Decoded-input and processed-output cumulative caps plus concrete held-tail cap. | +| Body-close hold is parser-context-aware and does not trigger on `