diff --git a/Cargo.lock b/Cargo.lock index 311597aae..965b84e7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5519,6 +5519,7 @@ dependencies = [ name = "trusted-server-integration-tests" version = "0.1.0" dependencies = [ + "async-trait", "axum", "bytes", "derive_more", diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 15b6ee59d..94ad02170 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -31,6 +31,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "ti trusted-server-core = { workspace = true } [dev-dependencies] +trusted-server-core = { workspace = true, features = ["test-utils"] } axum = { workspace = true } base64 = { workspace = true } temp-env = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 38776eb95..117ab33bd 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -51,6 +51,7 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + services: Option, } /// Build the application state, loading settings and constructing all per-application components. @@ -80,6 +81,13 @@ fn build_state() -> Result, Report> { /// registry fail to initialise. fn build_state_with_settings( settings: Settings, +) -> Result, Report> { + build_state_with_services(settings, None) +} + +fn build_state_with_services( + settings: Settings, + services: Option, ) -> Result, Report> { let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; @@ -90,9 +98,18 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + services, })) } +impl AppState { + fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { + self.services + .clone() + .unwrap_or_else(|| build_runtime_services(ctx)) + } +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -142,7 +159,7 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx); + let services = state.services_for_request(&ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, @@ -603,6 +620,30 @@ impl TrustedServerApp { let state = build_state_with_settings(settings)?; Ok(build_router(&state)) } + + /// Build the full router with explicit settings and runtime services. + /// + /// Each request receives a clone of the supplied services, allowing callers + /// to exercise production routes with deterministic platform dependencies. + /// The supplied client metadata applies to every request to this router. + /// + /// # Errors + /// + /// Returns an error when the auction orchestrator or integration registry + /// cannot be initialized. + /// + /// # Examples + /// + /// ```ignore + /// let router = TrustedServerApp::routes_with_settings_and_services(settings, services)?; + /// ``` + pub fn routes_with_settings_and_services( + settings: Settings, + services: RuntimeServices, + ) -> Result> { + let state = build_state_with_services(settings, Some(services))?; + Ok(build_router(&state)) + } } fn build_router(state: &Arc) -> RouterService { diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 6812b7421..7126b2a71 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -867,3 +867,74 @@ async fn first_party_proxy_rebuild_is_routed() { "/first-party/proxy-rebuild must be routed" ); } + +/// Regression test: a Next.js navigation with a pending auction must buffer to +/// the structural body close. The Flight payload carries a literal ``, so +/// a parser-blind seam would inject bids early and split the RSC data. +/// +/// This covers the buffered path only. This adapter routes navigations through +/// `buffer_publisher_response_async`, which resolves the body close without the +/// deferred inline seam marker, so the streaming seam token is exercised by the +/// Fastly adapter alone and not by this test. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn nextjs_auction_output_holds_until_the_structural_body_close() { + use std::sync::Arc; + + use trusted_server_core::test_support::nextjs_auction; + + let client = Arc::new(nextjs_auction::NextJsAuctionOrigin::default()); + let router = TrustedServerApp::routes_with_settings_and_services( + nextjs_auction::settings(), + nextjs_auction::services(Arc::clone(&client)), + ) + .expect("should build router with fixture services"); + + let request = edgezero_core::http::request_builder() + .method("GET") + .uri("https://test-publisher.example.com/article") + .header("host", "test-publisher.example.com") + .header("accept", "text/html") + .body(edgezero_core::body::Body::empty()) + .expect("should build publisher navigation"); + let response = router + .oneshot(request) + .await + .expect("should serve publisher navigation"); + assert_eq!(response.status(), 200, "should serve fixture HTML"); + let body = response + .into_body() + .into_bytes() + .expect("should buffer adapter output"); + let html = String::from_utf8(body.to_vec()).expect("should emit UTF-8 HTML"); + + assert_eq!( + client.auction_requests(), + 1, + "should dispatch exactly one auction" + ); + let bids = html + .find("var b=JSON.parse(") + .unwrap_or_else(|| panic!("should inject auction bids: {html}")); + let close = html + .rfind("") + .unwrap_or_else(|| panic!("should retain structural close: {html}")); + assert!( + bids < close && html[bids..].ends_with(""), + "should inject bids immediately before the structural body close: {html}" + ); + // The fixture splits the URL across two scripts, so the rewritten payload + // never appears contiguously. Assert on the recomputed `T` length instead: + // it shrinks only when the origin URL was actually replaced. + assert!( + html.contains(&nextjs_auction::expected_rewritten_flight_header()), + "should recompute the Flight T length after rewriting the URL: {html}" + ); + assert!( + !html.contains(nextjs_auction::ORIGIN_HOST), + "should leave no origin host in the rewritten payload: {html}" + ); + assert!( + !html.contains("__ts_rsc_") && !html.contains(""); - true + impl StreamProcessor for DecoratingProcessor { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> io::Result> { + if is_last { + self.final_calls.fetch_add(1, Ordering::SeqCst); + } + let mut output = vec![self.prefix]; + output.extend_from_slice(chunk); + Ok(output) } } - let mut processor = HtmlWithPostProcessing { - inner: HtmlRewriterAdapter::new(Settings::default()), - post_processors: vec![Arc::new(AppendCommentProcessor)], - accumulated_output: Vec::new(), - decoded_input_len: 0, - max_buffered_body_bytes: 16 * 1024 * 1024, - origin_host: String::new(), - request_host: String::new(), - request_scheme: String::new(), - document_state: IntegrationDocumentState::default(), + let inner_final_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let first_final_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let second_final_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut processor = HtmlWithStreamingProcessors { + inner: Box::new(DecoratingProcessor { + prefix: b'I', + final_calls: Arc::clone(&inner_final_calls), + }), + processors: vec![ + Box::new(DecoratingProcessor { + prefix: b'A', + final_calls: Arc::clone(&first_final_calls), + }), + Box::new(DecoratingProcessor { + prefix: b'B', + final_calls: Arc::clone(&second_final_calls), + }), + ], }; - // Feed multiple chunks - let r1 = processor - .process_chunk(b"", false) - .expect("should process chunk1"); - let r2 = processor - .process_chunk(b"

content

", false) - .expect("should process chunk2"); - let r3 = processor - .process_chunk(b"", true) - .expect("should process final chunk"); - - // Intermediate chunks return empty (buffered for post-processor) - assert!( - r1.is_empty() && r2.is_empty(), - "should buffer intermediate chunks" - ); - - // Final chunk contains the full document with post-processor mutation - let output = String::from_utf8(r3).expect("should be valid UTF-8"); - assert!( - output.contains("

content

"), - "should contain original content" - ); - assert!( - output.contains(""), - "should contain complete document" - ); - assert!( - output.contains(""), - "should contain post-processor mutation" + assert_eq!( + processor + .process_chunk(b"x", false) + .expect("should process intermediate chunk"), + b"BAIx", + "should emit intermediate output in registration order", ); + assert_eq!( + processor + .process_chunk(b"y", true) + .expect("should process final chunk"), + b"BAIy", + "should preserve processor order for final output", + ); + assert_eq!(inner_final_calls.load(Ordering::SeqCst), 1); + assert_eq!(first_final_calls.load(Ordering::SeqCst), 1); + assert_eq!(second_final_calls.load(Ordering::SeqCst), 1); } #[test] @@ -2167,6 +1882,131 @@ mod tests { ); } + #[test] + fn deferred_inline_marker_uses_only_the_structural_body_end() { + const TOKEN: &str = ""; + let state = + std::sync::Arc::new(std::sync::Mutex::new(Some("must-not-be-read".to_string()))); + let mut config = marker_mode_config(TOKEN, None); + config.body_close = BodyCloseInjection::DeferredInlineMarker(TOKEN.to_string()); + config.ad_bids_state = state; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk( + br#""#, + true, + ) + .expect("should process deferred marker document"); + let html = String::from_utf8(output).expect("output should be UTF-8"); + + assert_eq!( + html.matches(TOKEN).count(), + 1, + "should emit one marker: {html}" + ); + assert!( + html.contains(&format!("{TOKEN}")), + "marker should precede only the structural close: {html}" + ); + assert!(!html.contains("must-not-be-read")); + } + + #[test] + fn deferred_inline_marker_is_absent_without_an_explicit_body_end() { + const TOKEN: &str = ""; + let mut config = marker_mode_config(TOKEN, None); + config.body_close = BodyCloseInjection::DeferredInlineMarker(TOKEN.to_string()); + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"", true) + .expect("should process bodyless document"); + let html = String::from_utf8(output).expect("output should be UTF-8"); + + assert!( + !html.contains(TOKEN), + "bodyless document must have no marker: {html}" + ); + } + + #[test] + fn deferred_inline_marker_uses_parser_context_across_every_source_split() { + const TOKEN: &str = ""; + for source in [ + "

x

", + "

later

", + "

later

", + ] { + for split in 0..=source.len() { + let mut config = marker_mode_config(TOKEN, None); + config.body_close = BodyCloseInjection::DeferredInlineMarker(TOKEN.to_string()); + let mut processor = create_html_processor(config); + let mut output = processor + .process_chunk(&source.as_bytes()[..split], false) + .expect("should process first source fragment"); + output.extend( + processor + .process_chunk(&source.as_bytes()[split..], true) + .expect("should process final source fragment"), + ); + let html = String::from_utf8(output).expect("output should be UTF-8"); + assert_eq!( + html.matches(TOKEN).count(), + 1, + "should mark one structural close for split {split}: {html}" + ); + assert!( + html.to_ascii_lowercase() + .contains(&format!("{TOKEN}")), + "marker should precede structural close for split {split}: {html}" + ); + } + } + } + + #[test] + fn nextjs_output_overflow_restores_in_progress_script_at_every_split() { + let mut settings = create_test_settings(); + settings.integrations.insert( + "nextjs".to_owned(), + json!({"enabled": true, "max_combined_payload_bytes": 128}), + ); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile plan")), + ) + .expect("should create registry"); + let first = r#""#; + let script = r#"self.__next_f.push([1,"c"])"#; + let padding = "x".repeat(129); + let expected = format!("{first}{padding}"); + + for split in 1..script.len() { + let mut config = create_test_config(); + config.integrations = registry.clone(); + let mut processor = create_html_processor(config); + let mut output = processor + .process_chunk(first.as_bytes(), false) + .expect("should process unresolved RSC group"); + let second = format!("{padding}", &script[split..]); + output.extend( + processor + .process_chunk(third.as_bytes(), true) + .expect("should finish bypassed script"), + ); + assert_eq!( + String::from_utf8(output).expect("should retain UTF-8"), + expected, + "should restore all original bytes when overflow occurs at script split {split}" + ); + } + } + #[test] fn a_nonce_bearing_meta_policy_is_observed() { let observed = Arc::new(AtomicBool::new(false)); diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 671c94e5c..ae9ceb7cb 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -2517,6 +2517,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: true, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &doc_state, }; @@ -3430,6 +3431,7 @@ container_id = "GTM-DEFAULT" request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { @@ -3483,6 +3485,7 @@ container_id = "GTM-DEFAULT" request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { @@ -3525,6 +3528,7 @@ container_id = "GTM-DEFAULT" request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index c30b0e0c0..ee883b0d5 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -32,11 +32,11 @@ pub use registry::{ AttributeRewriteAction, AttributeRewriteOutcome, HeaderMutation, HeaderMutationMode, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationDocumentState, IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, - IntegrationHtmlPostProcessor, IntegrationMetadata, IntegrationProxy, IntegrationRegistration, - IntegrationRegistrationBuilder, IntegrationRegistry, IntegrationRequestFilter, - IntegrationScriptContext, IntegrationScriptRewriter, ProxyDispatchInput, RequestFilterDecision, - RequestFilterEffects, RequestFilterInput, RequestFilterRegistryInput, - RequestFilterRegistryOutcome, ScriptRewriteAction, + IntegrationHtmlStreamContext, IntegrationHtmlStreamProcessorFactory, IntegrationMetadata, + IntegrationProxy, IntegrationRegistration, IntegrationRegistrationBuilder, IntegrationRegistry, + IntegrationRequestFilter, IntegrationScriptContext, IntegrationScriptRewriter, + ProxyDispatchInput, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, + RequestFilterRegistryInput, RequestFilterRegistryOutcome, ScriptRewriteAction, }; /// Registers or retrieves a platform backend for the given URL. diff --git a/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs b/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs deleted file mode 100644 index 53e573db8..000000000 --- a/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs +++ /dev/null @@ -1,905 +0,0 @@ -use std::cell::{Cell, RefCell}; -use std::rc::Rc; -use std::sync::Arc; -use std::sync::Mutex; - -use lol_html::{Settings as RewriterSettings, text}; - -use crate::integrations::{IntegrationHtmlContext, IntegrationHtmlPostProcessor}; - -use super::rsc::rewrite_rsc_scripts_combined_with_limit; -use super::rsc_placeholders::{ - NextJsRscPostProcessState, RSC_PAYLOAD_PLACEHOLDER_PREFIX, RSC_PAYLOAD_PLACEHOLDER_SUFFIX, -}; -use super::shared::{RscUrlRewriter, find_rsc_push_payload_range}; -use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; - -pub(crate) struct NextJsHtmlPostProcessor { - config: Arc, -} - -impl NextJsHtmlPostProcessor { - pub(crate) fn new(config: Arc) -> Self { - Self { config } - } -} - -impl IntegrationHtmlPostProcessor for NextJsHtmlPostProcessor { - fn integration_id(&self) -> &'static str { - NEXTJS_INTEGRATION_ID - } - - fn should_process(&self, html: &str, ctx: &IntegrationHtmlContext<'_>) -> bool { - if !self.config.enabled || self.config.rewrite_attributes.is_empty() { - return false; - } - - // Check if we have captured placeholders from streaming - if let Some(state) = ctx - .document_state - .get::>(NEXTJS_INTEGRATION_ID) - { - let guard = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if !guard.payloads.is_empty() { - return true; - } - } - - // Also check if HTML contains RSC scripts that weren't captured during streaming - // (e.g., fragmented scripts that we skipped during the streaming pass) - html.contains("__next_f.push") && html.contains(ctx.origin_host) - } - - fn post_process(&self, html: &mut String, ctx: &IntegrationHtmlContext<'_>) -> bool { - // Try to get payloads captured during streaming (placeholder approach) - let payloads = ctx - .document_state - .get::>(NEXTJS_INTEGRATION_ID) - .map(|state| { - let mut guard = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - guard.take_payloads() - }) - .unwrap_or_default(); - - // Single rewriter instance shared across both code paths so the compiled - // regex is cached and reused regardless of which branch executes. - let rsc_rewriter = RscUrlRewriter::new(); - - if !payloads.is_empty() { - // Placeholder approach: substitute placeholders with rewritten payloads - return self.substitute_placeholders(html, ctx, payloads, &rsc_rewriter); - } - - // Fallback: re-parse HTML to find RSC scripts that weren't captured during streaming - // (e.g., fragmented scripts that we skipped during the streaming pass) - post_process_rsc_html_in_place_with_limit( - html, - ctx.origin_host, - ctx.request_host, - ctx.request_scheme, - self.config.max_combined_payload_bytes, - &rsc_rewriter, - ) - } -} - -impl NextJsHtmlPostProcessor { - /// Substitute placeholders with rewritten payloads (fast path for unfragmented scripts). - fn substitute_placeholders( - &self, - html: &mut String, - ctx: &IntegrationHtmlContext<'_>, - payloads: Vec, - rsc_rewriter: &RscUrlRewriter, - ) -> bool { - let payload_refs: Vec<&str> = payloads.iter().map(String::as_str).collect(); - let mut rewritten_payloads = rewrite_rsc_scripts_combined_with_limit( - payload_refs.as_slice(), - rsc_rewriter, - ctx.origin_host, - ctx.request_host, - ctx.request_scheme, - self.config.max_combined_payload_bytes, - ); - - if rewritten_payloads.len() != payloads.len() { - log::warn!( - "NextJs post-process skipping due to rewrite payload count mismatch: original={}, rewritten={}", - payloads.len(), - rewritten_payloads.len() - ); - rewritten_payloads = payloads; - } - - if log::log_enabled!(log::Level::Debug) { - let origin_count_before: usize = rewritten_payloads - .iter() - .map(|p| p.matches(ctx.origin_host).count()) - .sum(); - log::debug!( - "NextJs post-processor substituting RSC payloads: scripts={}, origin_urls={}, html_len={}", - rewritten_payloads.len(), - origin_count_before, - html.len() - ); - } - - let (updated, replaced) = - substitute_rsc_payload_placeholders(html.as_str(), &rewritten_payloads); - - let expected = rewritten_payloads.len(); - if replaced != expected { - log::warn!( - "NextJs post-process placeholder substitution count mismatch: expected={expected}, replaced={replaced}" - ); - } - - if contains_rsc_payload_placeholders(&updated) { - log::error!( - "NextJs post-process left RSC placeholders in output; attempting fallback substitution (scripts={expected})" - ); - - let fallback = - substitute_rsc_payload_placeholders_exact(html.as_str(), &rewritten_payloads); - - if contains_rsc_payload_placeholders(&fallback) { - log::error!( - "NextJs post-process fallback substitution still left RSC placeholders in output; hydration may break (scripts={expected})" - ); - } - - *html = fallback; - return true; - } - - *html = updated; - true - } -} - -fn contains_rsc_payload_placeholders(html: &str) -> bool { - let mut cursor = 0_usize; - while let Some(next) = html[cursor..].find(RSC_PAYLOAD_PLACEHOLDER_PREFIX) { - let start = cursor + next; - let after_prefix = start + RSC_PAYLOAD_PLACEHOLDER_PREFIX.len(); - let mut idx_end = after_prefix; - while idx_end < html.len() && html.as_bytes()[idx_end].is_ascii_digit() { - idx_end += 1; - } - if idx_end > after_prefix && html[idx_end..].starts_with(RSC_PAYLOAD_PLACEHOLDER_SUFFIX) { - return true; - } - cursor = after_prefix; - } - false -} - -fn substitute_rsc_payload_placeholders(html: &str, replacements: &[String]) -> (String, usize) { - let mut output = String::with_capacity(html.len()); - let mut cursor = 0_usize; - let mut replaced = 0_usize; - - while let Some(next) = html[cursor..].find(RSC_PAYLOAD_PLACEHOLDER_PREFIX) { - let start = cursor + next; - output.push_str(&html[cursor..start]); - - let after_prefix = start + RSC_PAYLOAD_PLACEHOLDER_PREFIX.len(); - let mut idx_end = after_prefix; - while idx_end < html.len() && html.as_bytes()[idx_end].is_ascii_digit() { - idx_end += 1; - } - - let suffix_ok = - idx_end > after_prefix && html[idx_end..].starts_with(RSC_PAYLOAD_PLACEHOLDER_SUFFIX); - if !suffix_ok { - output.push_str(RSC_PAYLOAD_PLACEHOLDER_PREFIX); - cursor = after_prefix; - continue; - } - - let idx_str = &html[after_prefix..idx_end]; - let Ok(index) = idx_str.parse::() else { - output.push_str(RSC_PAYLOAD_PLACEHOLDER_PREFIX); - output.push_str(idx_str); - output.push_str(RSC_PAYLOAD_PLACEHOLDER_SUFFIX); - cursor = idx_end + RSC_PAYLOAD_PLACEHOLDER_SUFFIX.len(); - continue; - }; - - let Some(replacement) = replacements.get(index) else { - output.push_str(RSC_PAYLOAD_PLACEHOLDER_PREFIX); - output.push_str(idx_str); - output.push_str(RSC_PAYLOAD_PLACEHOLDER_SUFFIX); - cursor = idx_end + RSC_PAYLOAD_PLACEHOLDER_SUFFIX.len(); - continue; - }; - - output.push_str(replacement); - replaced += 1; - cursor = idx_end + RSC_PAYLOAD_PLACEHOLDER_SUFFIX.len(); - } - - output.push_str(&html[cursor..]); - (output, replaced) -} - -fn substitute_rsc_payload_placeholders_exact(html: &str, replacements: &[String]) -> String { - let mut out = html.to_owned(); - for (index, replacement) in replacements.iter().enumerate() { - let placeholder = - format!("{RSC_PAYLOAD_PLACEHOLDER_PREFIX}{index}{RSC_PAYLOAD_PLACEHOLDER_SUFFIX}"); - out = out.replace(&placeholder, replacement); - } - out -} - -#[derive(Debug, Clone, Copy)] -struct RscPushScriptRange { - payload_start: usize, - payload_end: usize, -} - -fn find_rsc_push_scripts(html: &str) -> Vec { - if !html.contains("__next_f") { - return Vec::new(); - } - - let ranges: Rc>> = Rc::new(RefCell::new(Vec::new())); - let buffer: Rc> = Rc::new(RefCell::new(String::new())); - let buffering = Rc::new(Cell::new(false)); - let buffer_start = Rc::new(Cell::new(0_usize)); - - let settings = RewriterSettings { - element_content_handlers: vec![text!("script", { - let ranges = Rc::clone(&ranges); - let buffer = Rc::clone(&buffer); - let buffering = Rc::clone(&buffering); - let buffer_start = Rc::clone(&buffer_start); - move |t| { - if !buffering.get() && t.last_in_text_node() { - let script = t.as_str(); - if !script.contains("__next_f") { - return Ok(()); - } - - let Some((payload_start_rel, payload_end_rel)) = - find_rsc_push_payload_range(script) - else { - return Ok(()); - }; - - let loc = t.source_location().bytes(); - ranges.borrow_mut().push(RscPushScriptRange { - payload_start: loc.start + payload_start_rel, - payload_end: loc.start + payload_end_rel, - }); - return Ok(()); - } - - if !buffering.get() { - buffering.set(true); - buffer_start.set(t.source_location().bytes().start); - } - buffer.borrow_mut().push_str(t.as_str()); - - if !t.last_in_text_node() { - return Ok(()); - } - - buffering.set(false); - let script = std::mem::take(&mut *buffer.borrow_mut()); - if !script.contains("__next_f") { - return Ok(()); - } - - let Some((payload_start_rel, payload_end_rel)) = - find_rsc_push_payload_range(&script) - else { - return Ok(()); - }; - - let base = buffer_start.get(); - ranges.borrow_mut().push(RscPushScriptRange { - payload_start: base + payload_start_rel, - payload_end: base + payload_end_rel, - }); - - Ok(()) - } - })], - ..RewriterSettings::default() - }; - - let mut rewriter = lol_html::HtmlRewriter::new(settings, |_chunk: &[u8]| {}); - if rewriter.write(html.as_bytes()).is_err() || rewriter.end().is_err() { - return Vec::new(); - } - - std::mem::take(&mut *ranges.borrow_mut()) -} - -/// Rewrite RSC payload URLs in HTML by re-parsing the document. -/// -/// # Deprecation -/// -/// This function is **deprecated** in favor of the placeholder-based approach used in production: -/// - `NextJsRscPlaceholderRewriter` captures payloads during the initial `lol_html` pass -/// - `NextJsHtmlPostProcessor` rewrites and substitutes them at end-of-document -/// -/// This function re-parses HTML with `lol_html`, which is slower than the placeholder approach. -/// It remains available for testing and backward compatibility. -#[deprecated( - since = "0.1.0", - note = "Use NextJsHtmlPostProcessor for production RSC rewriting. This function re-parses HTML." -)] -#[must_use] -pub fn post_process_rsc_html( - html: &str, - origin_host: &str, - request_host: &str, - request_scheme: &str, -) -> String { - let mut result = html.to_owned(); - #[allow(deprecated, reason = "wrapper preserves the deprecated legacy API")] - post_process_rsc_html_in_place(&mut result, origin_host, request_host, request_scheme); - result -} - -/// Rewrite RSC payload URLs in HTML in place by re-parsing the document. -/// -/// # Deprecation -/// -/// This function is **deprecated** in favor of the placeholder-based approach used in production. -/// See [`post_process_rsc_html`] for details. -#[deprecated( - since = "0.1.0", - note = "Use NextJsHtmlPostProcessor for production RSC rewriting. This function re-parses HTML." -)] -pub fn post_process_rsc_html_in_place( - html: &mut String, - origin_host: &str, - request_host: &str, - request_scheme: &str, -) -> bool { - let rsc_rewriter = RscUrlRewriter::new(); - post_process_rsc_html_in_place_with_limit( - html, - origin_host, - request_host, - request_scheme, - super::rsc::DEFAULT_MAX_COMBINED_PAYLOAD_BYTES, - &rsc_rewriter, - ) -} - -fn post_process_rsc_html_in_place_with_limit( - html: &mut String, - origin_host: &str, - request_host: &str, - request_scheme: &str, - max_combined_payload_bytes: usize, - rsc_rewriter: &RscUrlRewriter, -) -> bool { - let mut scripts = find_rsc_push_scripts(html.as_str()); - if scripts.is_empty() { - return false; - } - - scripts.sort_by_key(|s| s.payload_start); - let mut previous_end = 0_usize; - for script in &scripts { - if script.payload_start > script.payload_end { - log::warn!( - "NextJs post-process skipping due to invalid payload range: start={}, end={}", - script.payload_start, - script.payload_end - ); - return false; - } - if script.payload_end > html.len() - || !html.is_char_boundary(script.payload_start) - || !html.is_char_boundary(script.payload_end) - { - log::warn!( - "NextJs post-process skipping due to non-UTF8 boundary payload range: start={}, end={}, html_len={}", - script.payload_start, - script.payload_end, - html.len() - ); - return false; - } - if script.payload_start < previous_end { - log::warn!( - "NextJs post-process skipping due to overlapping payload ranges: prev_end={}, start={}, end={}", - previous_end, - script.payload_start, - script.payload_end - ); - return false; - } - previous_end = script.payload_end; - } - - let rewritten_payloads = { - let Some(payloads) = scripts - .iter() - .map(|s| html.get(s.payload_start..s.payload_end)) - .collect::>>() - else { - log::warn!( - "NextJs post-process skipping due to invalid UTF-8 payload slicing despite boundary checks" - ); - return false; - }; - - if !payloads.iter().any(|p| p.contains(origin_host)) { - return false; - } - - if log::log_enabled!(log::Level::Debug) { - let origin_count_before: usize = payloads - .iter() - .map(|p| p.matches(origin_host).count()) - .sum(); - log::debug!( - "post_process_rsc_html: scripts={}, origin_urls={}", - payloads.len(), - origin_count_before - ); - } - - let rewritten_payloads = rewrite_rsc_scripts_combined_with_limit( - payloads.as_slice(), - rsc_rewriter, - origin_host, - request_host, - request_scheme, - max_combined_payload_bytes, - ); - - if rewritten_payloads.len() != payloads.len() { - log::warn!( - "NextJs post-process skipping due to rewrite payload count mismatch: original={}, rewritten={}", - payloads.len(), - rewritten_payloads.len() - ); - return false; - } - - let changed = payloads - .iter() - .zip(&rewritten_payloads) - .any(|(original, rewritten)| *original != rewritten); - if !changed { - return false; - } - - rewritten_payloads - }; - - for (i, script) in scripts.iter().enumerate().rev() { - html.replace_range( - script.payload_start..script.payload_end, - &rewritten_payloads[i], - ); - } - - true -} - -#[cfg(test)] -#[allow( - deprecated, - reason = "tests cover deprecated post_process_rsc_html legacy API" -)] -mod tests { - use super::*; - - fn find_rsc_push_scripts_chunked( - html: &str, - chunk_size: usize, - ) -> (Vec, bool) { - if !html.contains("__next_f") { - return (Vec::new(), false); - } - - let ranges: Rc>> = Rc::new(RefCell::new(Vec::new())); - let buffer: Rc> = Rc::new(RefCell::new(String::new())); - let buffering = Rc::new(Cell::new(false)); - let buffer_start = Rc::new(Cell::new(0_usize)); - let saw_partial = Rc::new(Cell::new(false)); - - let settings = RewriterSettings { - element_content_handlers: vec![text!("script", { - let ranges = Rc::clone(&ranges); - let buffer = Rc::clone(&buffer); - let buffering = Rc::clone(&buffering); - let buffer_start = Rc::clone(&buffer_start); - let saw_partial = Rc::clone(&saw_partial); - move |t| { - if !t.last_in_text_node() { - saw_partial.set(true); - } - - if !buffering.get() && t.last_in_text_node() { - let script = t.as_str(); - if !script.contains("__next_f") { - return Ok(()); - } - - let Some((payload_start_rel, payload_end_rel)) = - find_rsc_push_payload_range(script) - else { - return Ok(()); - }; - - let loc = t.source_location().bytes(); - ranges.borrow_mut().push(RscPushScriptRange { - payload_start: loc.start + payload_start_rel, - payload_end: loc.start + payload_end_rel, - }); - return Ok(()); - } - - if !buffering.get() { - buffering.set(true); - buffer_start.set(t.source_location().bytes().start); - } - buffer.borrow_mut().push_str(t.as_str()); - - if !t.last_in_text_node() { - return Ok(()); - } - - buffering.set(false); - let script = std::mem::take(&mut *buffer.borrow_mut()); - if !script.contains("__next_f") { - return Ok(()); - } - - let Some((payload_start_rel, payload_end_rel)) = - find_rsc_push_payload_range(&script) - else { - return Ok(()); - }; - - let base = buffer_start.get(); - ranges.borrow_mut().push(RscPushScriptRange { - payload_start: base + payload_start_rel, - payload_end: base + payload_end_rel, - }); - - Ok(()) - } - })], - ..RewriterSettings::default() - }; - - let mut rewriter = lol_html::HtmlRewriter::new(settings, |_chunk: &[u8]| {}); - let chunk_size = chunk_size.max(1); - for chunk in html.as_bytes().chunks(chunk_size) { - if rewriter.write(chunk).is_err() { - return (Vec::new(), saw_partial.get()); - } - } - if rewriter.end().is_err() { - return (Vec::new(), saw_partial.get()); - } - - let result = std::mem::take(&mut *ranges.borrow_mut()); - (result, saw_partial.get()) - } - - #[test] - fn post_process_rsc_html_rewrites_cross_script_tchunks() { - let html = r#" - - -"#; - - let result = post_process_rsc_html(html, "origin.example.com", "test.example.com", "https"); - - assert!( - result.contains("test.example.com/page"), - "URL should be rewritten. Got: {result}" - ); - assert!( - result.contains(":T3c,"), - "T-chunk length should be updated. Got: {result}" - ); - assert!(result.contains("") && result.contains("")); - assert!(result.contains("self.__next_f.push")); - } - - #[test] - fn finds_rsc_push_scripts_with_fragmented_script_text_chunks() { - let filler = "a".repeat(32 * 1024); - let payload = format!("{filler} https://origin.example.com/page"); - let html = format!( - r#""# - ); - - let (scripts, saw_partial) = find_rsc_push_scripts_chunked(&html, 64); - - assert!( - saw_partial, - "should observe fragmented script text chunks when writing input in small pieces" - ); - assert_eq!( - scripts.len(), - 1, - "Should find exactly one RSC payload script" - ); - - let extracted = &html[scripts[0].payload_start..scripts[0].payload_end]; - assert_eq!( - extracted.len(), - payload.len(), - "Extracted payload length should match the original payload" - ); - assert!( - extracted.ends_with("https://origin.example.com/page"), - "Extracted payload should contain the origin URL" - ); - } - - #[test] - fn finds_assignment_push_form() { - let html = r#""#; - let scripts = find_rsc_push_scripts(html); - assert_eq!( - scripts.len(), - 1, - "Should find exactly one RSC payload script" - ); - let payload = &html[scripts[0].payload_start..scripts[0].payload_end]; - assert_eq!(payload, "payload", "Should capture the payload string"); - } - - #[test] - fn finds_window_next_f_push_with_case_insensitive_script_tags() { - let html = ""; - let scripts = find_rsc_push_scripts(html); - assert_eq!( - scripts.len(), - 1, - "Should find exactly one RSC payload script" - ); - let payload = &html[scripts[0].payload_start..scripts[0].payload_end]; - assert_eq!(payload, "payload", "Should capture the payload string"); - } - - #[test] - fn post_process_rsc_html_handles_prettified_format() { - let html = r#" - - -"#; - - let result = post_process_rsc_html(html, "origin.example.com", "test.example.com", "https"); - - assert!( - result.contains("test.example.com/news"), - "First URL should be rewritten. Got: {result}" - ); - assert!( - result.contains("test.example.com/reviews"), - "Second URL should be rewritten. Got: {result}" - ); - assert!( - !result.contains("origin.example.com"), - "No origin URLs should remain. Got: {result}" - ); - assert!(result.contains("") && result.contains("")); - assert!(result.contains("self.__next_f.push")); - } - - #[test] - fn post_process_rewrites_html_href_inside_tchunk() { - fn calculate_unescaped_byte_length_for_test(s: &str) -> usize { - let bytes = s.as_bytes(); - let mut pos = 0_usize; - let mut count = 0_usize; - - while pos < bytes.len() { - if bytes[pos] == b'\\' && pos + 1 < bytes.len() { - let esc = bytes[pos + 1]; - - if matches!( - esc, - b'n' | b'r' | b't' | b'b' | b'f' | b'v' | b'"' | b'\'' | b'\\' | b'/' - ) { - pos += 2; - count += 1; - continue; - } - - if esc == b'x' && pos + 3 < bytes.len() { - pos += 4; - count += 1; - continue; - } - - if esc == b'u' && pos + 5 < bytes.len() { - let hex = &s[pos + 2..pos + 6]; - if hex.chars().all(|c| c.is_ascii_hexdigit()) - && let Ok(code_unit) = u16::from_str_radix(hex, 16) - { - // Surrogate pairs use UTF-16 and expand to 4 bytes in UTF-8. - if (0xD800..=0xDBFF).contains(&code_unit) - && pos + 11 < bytes.len() - && bytes[pos + 6] == b'\\' - && bytes[pos + 7] == b'u' - { - let hex2 = &s[pos + 8..pos + 12]; - if hex2.chars().all(|c| c.is_ascii_hexdigit()) - && let Ok(code_unit2) = u16::from_str_radix(hex2, 16) - && (0xDC00..=0xDFFF).contains(&code_unit2) - { - pos += 12; - count += 4; - continue; - } - } - - let c = char::from_u32(u32::from(code_unit)).unwrap_or('\u{FFFD}'); - pos += 6; - count += c.len_utf8(); - continue; - } - } - } - - if bytes[pos] < 0x80 { - pos += 1; - count += 1; - } else { - let c = s[pos..].chars().next().unwrap_or('\u{FFFD}'); - pos += c.len_utf8(); - count += c.len_utf8(); - } - } - - count - } - - let tchunk_content = r#"\u003cdiv\u003e\u003ca href="https://origin.example.com/about-us"\u003eAbout\u003c/a\u003e\u003c/div\u003e"#; - let declared_len_hex = format!( - "{:x}", - calculate_unescaped_byte_length_for_test(tchunk_content) - ); - let html = format!( - " - -" - ); - - let result = - post_process_rsc_html(&html, "origin.example.com", "test.example.com", "https"); - - assert!( - result.contains("test.example.com/about-us"), - "HTML href URL in T-chunk should be rewritten. Got: {result}" - ); - assert!( - !result.contains("origin.example.com"), - "No origin URLs should remain. Got: {result}" - ); - assert!( - !result.contains(&format!(":T{declared_len_hex},")), - "T-chunk length should have been recalculated. Got: {result}" - ); - } - - #[test] - fn handles_nextjs_inlined_data_nonce_fixture() { - // Fixture mirrors Next.js `createInlinedDataReadableStream` output: - // `` - let html = include_str!("fixtures/inlined-data-nonce.html"); - let scripts = find_rsc_push_scripts(html); - assert_eq!(scripts.len(), 1, "Should find exactly one RSC data script"); - - let rewritten = - post_process_rsc_html(html, "origin.example.com", "proxy.example.com", "https"); - assert!( - rewritten.contains("https://proxy.example.com/news"), - "Fixture URL should be rewritten. Got: {rewritten}" - ); - assert!( - !rewritten.contains("https://origin.example.com/news"), - "Origin URL should be removed. Got: {rewritten}" - ); - } - - #[test] - fn handles_nextjs_inlined_data_html_escaping_fixture() { - // Fixture includes `\\u003c` escapes, matching Next.js `htmlEscapeJsonString` behavior. - let html = include_str!("fixtures/inlined-data-escaped.html"); - let scripts = find_rsc_push_scripts(html); - assert_eq!(scripts.len(), 1, "Should find exactly one RSC data script"); - - let rewritten = - post_process_rsc_html(html, "origin.example.com", "proxy.example.com", "https"); - assert!( - rewritten.contains("https://proxy.example.com/about"), - "Escaped fixture URL should be rewritten. Got: {rewritten}" - ); - assert!( - rewritten.contains(r#"\\u003ca href=\\\"https://proxy.example.com/about\\\""#), - "Escaped HTML should remain escaped and rewritten. Got: {rewritten}" - ); - assert!( - !rewritten.contains("https://origin.example.com/about"), - "Origin URL should be removed. Got: {rewritten}" - ); - } - - #[test] - fn handles_trailing_backslash_gracefully() { - // Malformed content with trailing backslash should not panic - let html = r#" - - -"#; - - let scripts = find_rsc_push_scripts(html); - // The first script is malformed (trailing backslash escapes the quote), - // so it won't be detected as valid. The second one should be found. - assert!( - !scripts.is_empty(), - "Should find at least the valid script. Found: {}", - scripts.len() - ); - - // Should not panic during processing - let result = post_process_rsc_html(html, "origin.example.com", "test.example.com", "https"); - assert!( - result.contains("test.example.com") || result.contains("origin.example.com"), - "Processing should complete without panic" - ); - } - - #[test] - fn handles_unterminated_string_gracefully() { - // Content where string never closes - should not hang or panic - let html = r#" - -"#; - - let result = post_process_rsc_html(html, "origin.example.com", "test.example.com", "https"); - assert_eq!(result, html, "HTML without origin should be unchanged"); - } -} diff --git a/crates/trusted-server-core/src/integrations/nextjs/mod.rs b/crates/trusted-server-core/src/integrations/nextjs/mod.rs index 015ba2475..f6f36f67c 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/mod.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/mod.rs @@ -10,23 +10,16 @@ use crate::settings::{IntegrationConfig, Settings}; const NEXTJS_INTEGRATION_ID: &str = "nextjs"; -mod html_post_process; mod rsc; mod rsc_placeholders; +mod rsc_stream; mod script_rewriter; mod shared; -// Re-export deprecated legacy functions for backward compatibility. -// Production code should use the placeholder-based approach via NextJsHtmlPostProcessor. -#[allow( - deprecated, - reason = "legacy HTML post-processing functions remain re-exported for compatibility" -)] -pub use html_post_process::{post_process_rsc_html, post_process_rsc_html_in_place}; pub use rsc::rewrite_rsc_scripts_combined; -use html_post_process::NextJsHtmlPostProcessor; use rsc_placeholders::NextJsRscPlaceholderRewriter; +use rsc_stream::NextJsRscStreamProcessorFactory; use script_rewriter::NextJsNextDataRewriter; #[derive(Debug, Clone, Deserialize, Serialize, Validate)] @@ -94,17 +87,16 @@ pub fn register( // Register a structured (Pages Router __NEXT_DATA__) rewriter. let structured = Arc::new(NextJsNextDataRewriter::new(config.clone())?); - // Insert placeholders for App Router RSC payload scripts during the initial HTML rewrite pass, - // then substitute them during post-processing without re-parsing HTML. + // Insert placeholders for App Router RSC payload scripts during the HTML rewrite pass, + // then substitute them through the bounded output stream processor. let placeholders = Arc::new(NextJsRscPlaceholderRewriter::new(config.clone())); - // Register post-processor for cross-script RSC T-chunks - let post_processor = Arc::new(NextJsHtmlPostProcessor::new(config.clone())); + let stream_processor = Arc::new(NextJsRscStreamProcessorFactory::new(config.clone())); let builder = IntegrationRegistration::builder(NEXTJS_INTEGRATION_ID) .with_script_rewriter(structured) .with_script_rewriter(placeholders) - .with_html_post_processor(post_processor); + .with_html_stream_processor(stream_processor); Ok(Some(builder.build())) } @@ -693,19 +685,12 @@ mod tests { } /// Regression test: a fragmented `self.__next_f.push([1, "…"])` RSC script - /// must still have its origin URLs rewritten after going through the full - /// streaming pipeline into the accumulating post-processor. Exercises the - /// "fallback" branch of `NextJsHtmlPostProcessor` where no placeholders - /// were captured during streaming (because every fragment returned `Keep` - /// on `!is_last`) and `post_process_rsc_html_in_place_with_limit` has to - /// re-parse the accumulated HTML to find RSC push scripts. + /// must still have its origin URLs rewritten through the streaming pipeline. #[test] - fn small_chunk_rsc_push_survives_fragmentation_via_post_processor_fallback() { + fn small_chunk_rsc_push_survives_fragmentation() { // Build an RSC push script whose payload contains multiple origin URLs. // With chunk_size = 128, this script's text node will be fragmented at - // chunk boundaries by the streaming input, so NextJsRscPlaceholderRewriter - // will return Keep on every fragment and the post-processor fallback - // has to rewrite on the accumulated HTML. + // chunk boundaries by the streaming input. let html = format!( r#""#, "x".repeat(400), // pad to guarantee chunk-boundary fragmentation @@ -771,4 +756,185 @@ mod tests { "push call must close properly \u{2014} `\"])` followed by . Got: {processed}" ); } + + /// Build the production HTML processor for the Next.js integration and run + /// `html` through it at `chunk_size`, returning the streamed output. + fn stream_nextjs_html(html: &str, chunk_size: usize) -> String { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "nextjs", + &json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + }), + ) + .expect("should update nextjs config"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); + let processor = create_html_processor(config_from_settings(&settings, ®istry)); + let mut pipeline = StreamingPipeline::new( + PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size, + }, + processor, + ); + let mut output = Vec::new(); + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("should stream HTML"); + String::from_utf8(output).expect("should emit UTF-8 HTML") + } + + /// Regression test: only `self.`/`window.` own `__next_f`. A publisher script + /// that happens to hold a property of that name must stream through byte for + /// byte, at any fragmentation. + #[test] + fn foreign_next_f_receivers_stream_through_unchanged() { + for receiver in [ + "myAnalytics", + "foo.bar", + "window.myapp", + "a__next_f_store", + "myself", + ] { + let script = + format!(r#"{receiver}.__next_f.push([1,"https://origin.example.com/track"])"#); + let html = format!(""); + for chunk_size in [8, 32, 8192] { + let processed = stream_nextjs_html(&html, chunk_size); + assert_eq!( + processed, html, + "`{receiver}.__next_f` is not a Flight receiver and must not be rewritten at chunk size {chunk_size}" + ); + } + } + } + + /// Regression test: a genuine receiver must still be rewritten when the + /// stream splits it from its `__next_f` identifier. + #[test] + fn split_flight_receivers_are_still_rewritten() { + for receiver in ["self", "window"] { + let html = format!( + r#""# + ); + for chunk_size in [8, 32, 8192] { + let processed = stream_nextjs_html(&html, chunk_size); + assert!( + processed.contains("test.example.com/page") + && !processed.contains("origin.example.com/page"), + "`{receiver}.__next_f` should be rewritten at chunk size {chunk_size}. Got: {processed}" + ); + } + } + } + + /// Regression test: an escape whose body straddles a multi-byte character + /// must not panic the T-chunk escape scanner. + #[test] + fn malformed_escapes_at_character_boundaries_do_not_panic() { + for payload in [ + r#"1:T9,\x4ézzzzzzzz"#, + r#"1:T9,\u12😀zzzzzzzz"#, + r#"1:T9,\ud83d\u12😀zzzz"#, + ] { + let html = format!( + r#""# + ); + let processed = stream_nextjs_html(&html, 8192); + assert!( + processed.contains(payload), + "malformed escape payload should stream through unchanged. Got: {processed}" + ); + } + } + + /// Regression test: two independent payloads that each fit the configured + /// limit must both be rewritten, whether they arrive in one source chunk or + /// separate ones. The limit bounds one script and one unresolved group, not + /// every placeholder queued during a single parser call. + #[test] + fn independent_payloads_do_not_share_the_group_limit() { + let first = r#"{\"url\":\"https://origin.example.com/first\"}"#; + let second = r#"{\"url\":\"https://origin.example.com/second\"}"#; + let html = format!( + "\ + " + ); + + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "nextjs", + &json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + // Fits either script alone, not both payloads together. + "max_combined_payload_bytes": 80, + }), + ) + .expect("should update nextjs config"); + let registry = IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile auction plan"), + ), + ) + .expect("should create registry"); + let processor = create_html_processor(config_from_settings(&settings, ®istry)); + let mut pipeline = StreamingPipeline::new( + PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size: 8192, + }, + processor, + ); + let mut output = Vec::new(); + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("should stream HTML"); + let processed = String::from_utf8(output).expect("should emit UTF-8 HTML"); + + assert!( + processed.contains("test.example.com/first") + && processed.contains("test.example.com/second"), + "both independent payloads should be rewritten in one parser call. Got: {processed}" + ); + assert!( + !processed.contains("origin.example.com"), + "no origin host should survive. Got: {processed}" + ); + } + + /// Regression test: Next.js always emits a `self.`/`window.` receiver. A bare + /// `__next_f.push(...)` has no receiver to verify, and the boundary check + /// cannot reject it because nothing precedes the identifier, so only the + /// qualified-receiver requirement keeps it from being claimed. + #[test] + fn unqualified_next_f_push_streams_through_unchanged() { + let html = concat!( + r#""# + ); + for chunk_size in [8, 32, 8192] { + let processed = stream_nextjs_html(html, chunk_size); + assert_eq!( + processed, html, + "an unqualified `__next_f` receiver should stream through unchanged at chunk size {chunk_size}" + ); + } + } } diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc.rs index fbd2c693c..3062c4a42 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc.rs @@ -20,7 +20,7 @@ pub(crate) const DEFAULT_MAX_COMBINED_PAYLOAD_BYTES: usize = 10 * 1024 * 1024; /// Maximum reasonable T-chunk length to prevent `DoS` from malformed input (100 MB). /// A `T-chunk` larger than this is almost certainly malformed and would cause excessive /// memory allocation or iteration. -const MAX_REASONABLE_TCHUNK_LENGTH: usize = 100 * 1024 * 1024; +pub(super) const MAX_REASONABLE_TCHUNK_LENGTH: usize = 100 * 1024 * 1024; // ============================================================================= // Escape Sequence Parsing @@ -115,51 +115,68 @@ impl Iterator for EscapeSequenceIter<'_> { return Some(EscapeElement { byte_count: 1 }); } - if esc == b'x' && self.pos + 3 < self.bytes.len() { + // Only the boundary is checked, not the hex digits: validating the + // digits would change the unescaped byte count of malformed but + // previously scannable input such as `\xZZ`, and that count drives + // T-chunk length recomputation. Inputs that are rejected here are + // exactly the ones that used to advance into a character and panic. + if esc == b'x' + && self.pos + 3 < self.bytes.len() + && self.str_ref.is_char_boundary(self.pos + 4) + { self.pos += 4; return Some(EscapeElement { byte_count: 1 }); } - if esc == b'u' && self.pos + 5 < self.bytes.len() { - let hex = &self.str_ref[self.pos + 2..self.pos + 6]; - if hex.chars().all(|c| c.is_ascii_hexdigit()) - && let Ok(code_unit) = u16::from_str_radix(hex, 16) + // `str::get` yields `None` when the escape body straddles a character, + // which falls through to literal handling exactly as invalid hex does. + if esc == b'u' + && self.pos + 5 < self.bytes.len() + && let Some(hex) = self.str_ref.get(self.pos + 2..self.pos + 6) + && hex.chars().all(|c| c.is_ascii_hexdigit()) + && let Ok(code_unit) = u16::from_str_radix(hex, 16) + { + if (0xD800..=0xDBFF).contains(&code_unit) + && self.pos + 11 < self.bytes.len() + && self.bytes[self.pos + 6] == b'\\' + && self.bytes[self.pos + 7] == b'u' { - if (0xD800..=0xDBFF).contains(&code_unit) - && self.pos + 11 < self.bytes.len() - && self.bytes[self.pos + 6] == b'\\' - && self.bytes[self.pos + 7] == b'u' + let hex2 = self.str_ref.get(self.pos + 8..self.pos + 12); + if let Some(hex2) = hex2 + && hex2.chars().all(|c| c.is_ascii_hexdigit()) + && let Ok(code_unit2) = u16::from_str_radix(hex2, 16) + && (0xDC00..=0xDFFF).contains(&code_unit2) { - let hex2 = &self.str_ref[self.pos + 8..self.pos + 12]; - if hex2.chars().all(|c| c.is_ascii_hexdigit()) - && let Ok(code_unit2) = u16::from_str_radix(hex2, 16) - && (0xDC00..=0xDFFF).contains(&code_unit2) - { - self.pos += 12; - return Some(EscapeElement { byte_count: 4 }); - } + self.pos += 12; + return Some(EscapeElement { byte_count: 4 }); } - - let c = char::from_u32(u32::from(code_unit)).unwrap_or('\u{FFFD}'); - self.pos += 6; - return Some(EscapeElement { - byte_count: c.len_utf8(), - }); } + + let c = char::from_u32(u32::from(code_unit)).unwrap_or('\u{FFFD}'); + self.pos += 6; + return Some(EscapeElement { + byte_count: c.len_utf8(), + }); } } if self.bytes[self.pos] < 0x80 { self.pos += 1; Some(EscapeElement { byte_count: 1 }) - } else { - let c = self.str_ref[self.pos..] - .chars() - .next() - .unwrap_or('\u{FFFD}'); + } else if let Some(c) = self + .str_ref + .get(self.pos..) + .and_then(|remainder| remainder.chars().next()) + { let len = c.len_utf8(); self.pos += len; Some(EscapeElement { byte_count: len }) + } else { + // Defensive: the escape guards above keep `pos` on a character + // boundary, so a continuation byte here is unreachable. Advance one + // byte rather than slicing so the iterator cannot panic or stall. + self.pos += 1; + Some(EscapeElement { byte_count: 1 }) } } } @@ -189,83 +206,161 @@ fn consume_unescaped_bytes(s: &str, start_pos: usize, byte_count: usize) -> (usi // ============================================================================= /// Information about a T-chunk found in the combined RSC content. -struct TChunkInfo { +pub(super) struct TChunkInfo { /// Position where the T-chunk header starts (e.g., position of "1a:T..."). - match_start: usize, + pub(super) match_start: usize, /// Position right after the chunk ID (position of ":T"). - id_end: usize, + pub(super) id_end: usize, /// Position right after the comma (where content begins). - header_end: usize, + pub(super) header_end: usize, /// Position where the content ends. - content_end: usize, + pub(super) content_end: usize, } -/// Find all T-chunks in content, optionally skipping markers. -fn find_tchunks_impl(content: &str, skip_markers: bool) -> Option> { - let mut chunks = Vec::new(); - let mut search_pos = 0; - let marker = skip_markers.then(|| RSC_MARKER.as_bytes()); +pub(super) enum TChunkScan { + Complete(Vec), + NeedMore, + Invalid, +} + +/// Longest escape sequence in source bytes: `\uD83D\uDE00`. +const MAX_ESCAPE_SEQUENCE_BYTES: usize = 12; - while search_pos < content.len() { - if let Some(cap) = TCHUNK_PATTERN.captures(&content[search_pos..]) { - let m = cap.get(0).expect("T-chunk match should exist"); - let match_start = search_pos + m.start(); - let header_end = search_pos + m.end(); +/// A T-chunk whose header has been parsed but whose content has not fully arrived. +pub(super) struct PendingTChunk { + match_start: usize, + id_end: usize, + header_end: usize, + declared_length: usize, + consumed: usize, + pos: usize, +} + +/// Outcome of advancing a T-chunk scan by one chunk. +pub(super) enum TChunkStep { + /// A chunk's declared content is fully present. + Found(TChunkInfo), + /// The text ends inside this chunk's content; resume with more text. + Pending(PendingTChunk), + /// No further chunk header begins in the text. + Exhausted, + Invalid, +} +/// Advance a T-chunk scan by one chunk. +/// +/// Passing the [`TChunkStep::Pending`] chunk back resumes content consumption +/// where it stopped, so growing text is walked once rather than re-walked from +/// the chunk header on every call. +/// +/// `hold_back_partial_escape` stops the scan at a trailing backslash that could +/// still grow into a longer escape sequence. A resumed scan would otherwise +/// commit to reading that backslash as a literal byte. +pub(super) fn next_tchunk( + content: &str, + search_pos: usize, + pending: Option, + marker: Option<&[u8]>, + hold_back_partial_escape: bool, +) -> TChunkStep { + let mut chunk = match pending { + Some(chunk) => chunk, + None => { + if search_pos >= content.len() { + return TChunkStep::Exhausted; + } + let Some(cap) = TCHUNK_PATTERN.captures(&content[search_pos..]) else { + return TChunkStep::Exhausted; + }; + let call = cap.get(0).expect("T-chunk match should exist"); let id_match = cap.get(1).expect("T-chunk id should exist"); - let id_end = search_pos + id_match.end(); let length_hex = cap.get(2).expect("T-chunk length should exist").as_str(); - let declared_length = usize::from_str_radix(length_hex, 16) + let Some(declared_length) = usize::from_str_radix(length_hex, 16) .ok() - .filter(|&len| len <= MAX_REASONABLE_TCHUNK_LENGTH)?; - - let content_end = if let Some(marker_bytes) = marker { - let mut iter = EscapeSequenceIter::from_position_with_marker( - content, - header_end, - marker_bytes, - ); - let mut consumed = 0; - while consumed < declared_length { - match iter.next() { - Some(elem) => consumed += elem.byte_count, - None => break, - } - } - if consumed < declared_length { - return None; - } - iter.position() - } else { - let (pos, consumed) = consume_unescaped_bytes(content, header_end, declared_length); - if consumed < declared_length { - return None; - } - pos + .filter(|&len| len <= MAX_REASONABLE_TCHUNK_LENGTH) + else { + return TChunkStep::Invalid; }; - - chunks.push(TChunkInfo { - match_start, - id_end, + let header_end = search_pos + call.end(); + PendingTChunk { + match_start: search_pos + call.start(), + id_end: search_pos + id_match.end(), header_end, - content_end, - }); + declared_length, + consumed: 0, + pos: header_end, + } + } + }; - search_pos = content_end; - } else { + let mut iter = match marker { + Some(marker) => EscapeSequenceIter::from_position_with_marker(content, chunk.pos, marker), + None => EscapeSequenceIter::from_position(content, chunk.pos), + }; + while chunk.consumed < chunk.declared_length { + let position = iter.position(); + if hold_back_partial_escape + && content.as_bytes()[position..].first() == Some(&b'\\') + && content.len() - position < MAX_ESCAPE_SEQUENCE_BYTES + { break; } + match iter.next() { + Some(element) => chunk.consumed += element.byte_count, + None => break, + } + } + chunk.pos = iter.position(); + + if chunk.consumed > chunk.declared_length { + return TChunkStep::Invalid; + } + if chunk.consumed < chunk.declared_length { + return TChunkStep::Pending(chunk); + } + TChunkStep::Found(TChunkInfo { + match_start: chunk.match_start, + id_end: chunk.id_end, + header_end: chunk.header_end, + content_end: chunk.pos, + }) +} + +/// Find all T-chunks in content, optionally skipping markers. +fn scan_tchunks_impl(content: &str, skip_markers: bool) -> TChunkScan { + let marker = skip_markers.then(|| RSC_MARKER.as_bytes()); + let mut chunks = Vec::new(); + let mut search_pos = 0; + + loop { + match next_tchunk(content, search_pos, None, marker, false) { + TChunkStep::Found(chunk) => { + search_pos = chunk.content_end; + chunks.push(chunk); + } + TChunkStep::Pending(_) => return TChunkScan::NeedMore, + TChunkStep::Exhausted => return TChunkScan::Complete(chunks), + TChunkStep::Invalid => return TChunkScan::Invalid, + } } +} - Some(chunks) +pub(super) fn scan_tchunks(content: &str) -> TChunkScan { + scan_tchunks_impl(content, false) } fn find_tchunks(content: &str) -> Option> { - find_tchunks_impl(content, false) + match scan_tchunks(content) { + TChunkScan::Complete(chunks) => Some(chunks), + TChunkScan::NeedMore | TChunkScan::Invalid => None, + } } fn find_tchunks_with_markers(content: &str) -> Option> { - find_tchunks_impl(content, true) + match scan_tchunks_impl(content, true) { + TChunkScan::Complete(chunks) => Some(chunks), + TChunkScan::NeedMore | TChunkScan::Invalid => None, + } } // ============================================================================= @@ -572,6 +667,20 @@ mod tests { assert_eq!(calculate_unescaped_byte_length(r"\u00e9"), 2); } + #[test] + fn rejects_tchunk_lengths_that_split_a_decoded_character() { + for content in ["1:T1,€", r"1:T1,\ud83d\ude00"] { + assert!( + matches!(scan_tchunks(content), TChunkScan::Invalid), + "plain scanner should reject a split decoded character: {content}" + ); + assert!( + matches!(scan_tchunks_impl(content, true), TChunkScan::Invalid), + "marker-aware scanner should reject a split decoded character: {content}" + ); + } + } + #[test] fn multiple_tchunks() { let content = r#"1a:T1c,{"url":"https://short.io/x"}\n1b:T1c,{"url":"https://short.io/y"}"#; diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs index 117b8f982..b881c32e1 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs @@ -1,35 +1,22 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use crate::integrations::{ IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction, }; -use super::shared::find_rsc_push_payload_range; +use super::rsc::DEFAULT_MAX_COMBINED_PAYLOAD_BYTES; +#[cfg(test)] +pub(super) use super::rsc_stream::RSC_PAYLOAD_PLACEHOLDER_PREFIX; +use super::rsc_stream::{ + CapturedPayload, FragmentCapture, MAX_UNRESOLVED_RSC_PAYLOADS, RscGroupStatus, + capture_fragment, classify_rsc_group, document_state, rsc_payload_placeholder, +}; +use super::shared::{ + RSC_RECEIVER_CONTEXT_BYTES, find_rsc_push_payload_range, find_trimmed_rsc_push_payload_range, + receiver_context_is_flight_push, +}; use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; -pub(super) const RSC_PAYLOAD_PLACEHOLDER_PREFIX: &str = "__ts_rsc_payload_"; -pub(super) const RSC_PAYLOAD_PLACEHOLDER_SUFFIX: &str = "__"; - -/// State for RSC placeholder-based rewriting. -/// -/// Stores RSC payloads extracted during streaming for later rewriting during post-processing. -/// Only unfragmented RSC scripts are processed during streaming; fragmented scripts are -/// handled by the post-processor which re-parses the final HTML. -#[derive(Default)] -pub(super) struct NextJsRscPostProcessState { - pub(super) payloads: Vec, -} - -impl NextJsRscPostProcessState { - pub(super) fn take_payloads(&mut self) -> Vec { - std::mem::take(&mut self.payloads) - } -} - -fn rsc_payload_placeholder(index: usize) -> String { - format!("{RSC_PAYLOAD_PLACEHOLDER_PREFIX}{index}{RSC_PAYLOAD_PLACEHOLDER_SUFFIX}") -} - pub(super) struct NextJsRscPlaceholderRewriter { config: Arc, } @@ -38,6 +25,128 @@ impl NextJsRscPlaceholderRewriter { pub(super) fn new(config: Arc) -> Self { Self { config } } + + fn rewrite_complete( + &self, + content: &str, + was_buffered: bool, + state: &mut super::rsc_stream::NextJsDocumentState, + limit: usize, + max_queued_payload_bytes: usize, + ) -> ScriptRewriteAction { + if !content.contains("__next_f") { + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + } + + let range = if state.rsc_receiver_trimmed { + find_trimmed_rsc_push_payload_range(content) + } else { + find_rsc_push_payload_range(content) + }; + state.rsc_receiver_trimmed = false; + let Some((payload_start, payload_end)) = range else { + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + }; + + if payload_start > payload_end + || payload_end > content.len() + || !content.is_char_boundary(payload_start) + || !content.is_char_boundary(payload_end) + { + state.bypass_rsc = true; + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + } + + let payload = &content[payload_start..payload_end]; + // `limit` bounds one script here and the downstream unresolved group in + // `classify_rsc_group`. It deliberately does not bound this queue in + // aggregate: the processor cannot decrement a resolved group until the + // whole parser call returns, so a shared `limit` budget would make + // independent payloads bypass each other purely because they shared a + // source chunk. The queue is parser-held script text, so it is bounded + // by the parser's own script-buffer budget instead — the same budget + // `NextJsNextDataRewriter` buffers against — plus a payload count. + let exceeds_limit = payload.len() > limit + || state.captured_payloads.len() >= MAX_UNRESOLVED_RSC_PAYLOADS + || state + .captured_payload_bytes + .checked_add(payload.len()) + .is_none_or(|queued| queued > max_queued_payload_bytes); + if exceeds_limit { + state.bypass_rsc = true; + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + } + + let placeholder = rsc_payload_placeholder(&state.namespace, state.next_placeholder_index); + state.next_placeholder_index = state.next_placeholder_index.saturating_add(1); + state.captured_payload_bytes += payload.len(); + state.captured_payloads.push_back(CapturedPayload { + placeholder: placeholder.clone(), + original: payload.to_owned(), + }); + + let mut rewritten = content.to_owned(); + rewritten.replace_range(payload_start..payload_end, &placeholder); + ScriptRewriteAction::replace(rewritten) + } + + fn rewrite_claimed_fragment( + &self, + content: &str, + is_last: bool, + state: &mut super::rsc_stream::NextJsDocumentState, + limit: usize, + max_queued_payload_bytes: usize, + ) -> ScriptRewriteAction { + match capture_fragment(&mut state.rsc_script, content, is_last, limit) { + FragmentCapture::CompleteBorrowed(complete) => { + self.rewrite_complete(complete, false, state, limit, max_queued_payload_bytes) + } + FragmentCapture::CompleteOwned(complete) => { + self.rewrite_complete(&complete, true, state, limit, max_queued_payload_bytes) + } + FragmentCapture::Suppress => ScriptRewriteAction::RemoveNode, + FragmentCapture::Restore(restored) => { + state.rsc_receiver_trimmed = false; + state.bypass_rsc = true; + ScriptRewriteAction::replace(restored) + } + FragmentCapture::PassThrough => { + if is_last && content.len() > limit && content.contains("__next_f") { + let unsafe_continuation = find_rsc_push_payload_range(content) + .map(|(start, end)| { + matches!( + classify_rsc_group(&[&content[start..end]], limit), + RscGroupStatus::NeedMore | RscGroupStatus::Invalid + ) + }) + .unwrap_or(true); + state.bypass_rsc |= unsafe_continuation + || state.captured_payload_bytes > 0 + || !state.captured_payloads.is_empty(); + } else if !is_last && content.len() > limit { + state.bypass_rsc = true; + } + ScriptRewriteAction::Keep + } + } + } } impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { @@ -54,53 +163,161 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { return ScriptRewriteAction::keep(); } - // Deliberately does not accumulate fragments (unlike NextJsNextDataRewriter - // and GoogleTagManagerIntegration which use Mutex buffers). RSC - // placeholder processing has a post-processor fallback that re-parses - // the final HTML at end-of-document, so fragmented scripts are safely - // deferred. Accumulation here would also risk corrupting non-RSC scripts - // that happen to be fragmented during streaming. - if !ctx.is_last_in_text_node { + let state = document_state(ctx.document_state); + let mut state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.bypass_rsc { + // The downstream processor can enter bypass after the parser has + // suppressed part of this script. Restore it before passing through + // the next fragment so an output limit cannot truncate JavaScript. + let mut restored = match std::mem::take(&mut state.rsc_script) { + super::rsc_stream::FragmentState::Buffering(buffer) => buffer, + _ => String::new(), + }; + restored.push_str(&std::mem::take(&mut state.rsc_probe)); + state.rsc_receiver_context.clear(); + state.rsc_receiver_trimmed = false; + if !restored.is_empty() { + restored.push_str(content); + return ScriptRewriteAction::replace(restored); + } return ScriptRewriteAction::keep(); } + let limit = if self.config.max_combined_payload_bytes == 0 { + DEFAULT_MAX_COMBINED_PAYLOAD_BYTES + } else { + self.config.max_combined_payload_bytes + }; + if !matches!(state.rsc_script, super::rsc_stream::FragmentState::Idle) { + return self.rewrite_claimed_fragment( + content, + ctx.is_last_in_text_node, + &mut state, + limit, + ctx.max_buffered_script_bytes, + ); + } - // Quick check: skip scripts that can't be RSC payloads - if !content.contains("__next_f") { - return ScriptRewriteAction::keep(); + if state.rsc_probe.is_empty() && !content.contains("__next_f") { + if ctx.is_last_in_text_node { + state.rsc_receiver_context.clear(); + return ScriptRewriteAction::Keep; + } + let probe_length = longest_identifier_prefix(content.as_bytes()); + let ready_length = content.len() - probe_length; + state.rsc_probe.push_str(&content[ready_length..]); + remember_released(&mut state.rsc_receiver_context, &content[..ready_length]); + return if probe_length == 0 { + ScriptRewriteAction::Keep + } else if ready_length == 0 { + ScriptRewriteAction::RemoveNode + } else { + ScriptRewriteAction::replace(&content[..ready_length]) + }; } - let Some((payload_start, payload_end)) = find_rsc_push_payload_range(content) else { - // Contains __next_f but doesn't match RSC push pattern - leave unchanged - return ScriptRewriteAction::keep(); - }; + let prior_probe = std::mem::take(&mut state.rsc_probe); + let mut combined = prior_probe.clone(); + combined.push_str(content); + if !combined.contains("__next_f") { + if ctx.is_last_in_text_node { + state.rsc_receiver_context.clear(); + return if prior_probe.is_empty() { + ScriptRewriteAction::Keep + } else { + ScriptRewriteAction::replace(combined) + }; + } + let probe_length = longest_identifier_prefix(combined.as_bytes()); + let ready_length = combined.len() - probe_length; + state.rsc_probe.push_str(&combined[ready_length..]); + remember_released(&mut state.rsc_receiver_context, &combined[..ready_length]); + if prior_probe.is_empty() && probe_length == 0 { + return ScriptRewriteAction::Keep; + } + return if ready_length == 0 { + ScriptRewriteAction::RemoveNode + } else { + ScriptRewriteAction::replace(&combined[..ready_length]) + }; + } - if payload_start > payload_end - || payload_end > content.len() - || !content.is_char_boundary(payload_start) - || !content.is_char_boundary(payload_end) - { - return ScriptRewriteAction::keep(); + let identifier_start = combined + .find("__next_f") + .expect("should find the identifier that selected this branch"); + let mut context = state.rsc_receiver_context.clone(); + context.push_str(&combined[..identifier_start]); + if !receiver_context_is_flight_push(&context) { + // Some other object owns a `__next_f` property. Release the text + // unchanged rather than claiming an unrelated publisher script. + if ctx.is_last_in_text_node { + state.rsc_receiver_context.clear(); + } else { + remember_released(&mut state.rsc_receiver_context, &combined); + } + return if prior_probe.is_empty() { + ScriptRewriteAction::Keep + } else { + ScriptRewriteAction::replace(combined) + }; } - // Insert placeholder for this RSC payload and store original for post-processing - let state = ctx - .document_state - .get_or_insert_with(NEXTJS_INTEGRATION_ID, || { - Mutex::new(NextJsRscPostProcessState::default()) - }); - let mut guard = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + // A receiver that survives inside `combined` keeps the claim qualified; + // one that already streamed leaves a trimmed claim whose receiver this + // verified context stands in for. + state.rsc_receiver_trimmed = + !receiver_context_is_flight_push(&combined[..identifier_start]); + state.rsc_receiver_context.clear(); + let claimed_start = if state.rsc_receiver_trimmed { + identifier_start + } else { + 0 + }; + let prefix = &combined[..claimed_start]; + let claimed = &combined[claimed_start..]; + let action = self.rewrite_claimed_fragment( + claimed, + ctx.is_last_in_text_node, + &mut state, + limit, + ctx.max_buffered_script_bytes, + ); + match action { + ScriptRewriteAction::RemoveNode if prefix.is_empty() => ScriptRewriteAction::RemoveNode, + ScriptRewriteAction::RemoveNode => ScriptRewriteAction::replace(prefix), + ScriptRewriteAction::Replace(rewritten) => { + ScriptRewriteAction::replace(format!("{prefix}{rewritten}")) + } + ScriptRewriteAction::Keep if prior_probe.is_empty() => ScriptRewriteAction::Keep, + ScriptRewriteAction::Keep => ScriptRewriteAction::replace(combined), + } + } +} - let placeholder_index = guard.payloads.len(); - let placeholder = rsc_payload_placeholder(placeholder_index); - guard - .payloads - .push(content[payload_start..payload_end].to_string()); +/// Bytes to withhold so a `__next_f` identifier split across text fragments can +/// still be recognized once the next fragment arrives. +fn longest_identifier_prefix(bytes: &[u8]) -> usize { + let identifier = b"__next_f"; + let maximum = bytes.len().min(identifier.len().saturating_sub(1)); + (1..=maximum) + .rev() + .find(|length| bytes.ends_with(&identifier[..*length])) + .unwrap_or(0) +} - let mut rewritten = content.to_owned(); - rewritten.replace_range(payload_start..payload_end, &placeholder); - ScriptRewriteAction::replace(rewritten) +/// Retain the tail of released script text so a receiver that streams before its +/// `__next_f` identifier is recognized can still be verified. +fn remember_released(context: &mut String, released: &str) { + context.push_str(released); + if context.len() > RSC_RECEIVER_CONTEXT_BYTES { + let start = context.len() - RSC_RECEIVER_CONTEXT_BYTES; + // Character boundaries only matter for the ASCII receiver spellings, so a + // split multi-byte character can be dropped along with the excess. + let start = (start..context.len()) + .find(|index| context.is_char_boundary(*index)) + .unwrap_or(context.len()); + context.drain(..start); } } @@ -108,6 +325,7 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { mod tests { use super::*; use crate::integrations::IntegrationDocumentState; + use crate::integrations::nextjs::rsc_stream::NextJsDocumentState; fn ctx( is_last_in_text_node: bool, @@ -119,6 +337,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state, } } @@ -148,48 +367,98 @@ mod tests { ); let stored = state - .get::>(NEXTJS_INTEGRATION_ID) + .get::>(NEXTJS_INTEGRATION_ID) .expect("should store RSC state"); let guard = stored.lock().expect("should lock Next.js RSC state"); - assert_eq!(guard.payloads.len(), 1, "Should store exactly one payload"); assert_eq!( - guard.payloads[0], "https://origin.example.com/page", + guard.captured_payloads.len(), + 1, + "Should store exactly one payload" + ); + assert_eq!( + guard + .captured_payloads + .front() + .expect("should contain captured payload") + .original, + "https://origin.example.com/page", "Stored payload should match original" ); } #[test] - fn skips_fragmented_scripts_for_post_processor_handling() { - // Fragmented scripts are not processed during streaming - they're passed through - // unchanged and handled by the post-processor which re-parses the final HTML. + fn captures_fragmented_scripts_as_one_namespaced_placeholder() { let state = IntegrationDocumentState::default(); let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); let first = "self.__next_f.push([1,\"https://origin.example.com"; let second = "/page\"])"; - // Intermediate chunk should be kept (not processed) let action_first = rewriter.rewrite(first, &ctx(false, &state)); assert_eq!( action_first, - ScriptRewriteAction::Keep, - "Intermediate chunk should be kept unchanged" + ScriptRewriteAction::RemoveNode, + "should suppress a bounded intermediate fragment" ); - // Final chunk should also be kept since it doesn't contain the full RSC pattern let action_second = rewriter.rewrite(second, &ctx(true, &state)); + assert!( + matches!(action_second, ScriptRewriteAction::Replace(ref value) if value.contains("__ts_rsc_")), + "should emit one request-namespaced placeholder", + ); + } + + #[test] + fn captures_initializer_push_at_every_fragment_boundary() { + let script = r#"(self.__next_f=self.__next_f||[]).push([1,"1:T3,ab"])"#; + for split in 1..script.len() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let _ = rewriter.rewrite(&script[..split], &ctx(false, &state)); + let _ = rewriter.rewrite(&script[split..], &ctx(true, &state)); + let shared = document_state(&state); + let guard = shared.lock().expect("should lock document state"); + assert_eq!( + guard.captured_payloads.len(), + 1, + "should capture initializer split at byte {split}" + ); + assert_eq!( + guard.captured_payloads[0].original, "1:T3,ab", + "should capture complete payload" + ); + } + } + + #[test] + fn overflowing_fragmented_rsc_restores_prefix_and_bypasses_later_rsc() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(Arc::new(NextJsIntegrationConfig { + max_combined_payload_bytes: 24, + ..(*test_config()).clone() + })); + + assert_eq!( + rewriter.rewrite("self.__next_f", &ctx(false, &state)), + ScriptRewriteAction::RemoveNode, + "should initially suppress the script prefix", + ); + assert_eq!( + rewriter.rewrite("-payload-overflow", &ctx(false, &state)), + ScriptRewriteAction::Replace("self.__next_f-payload-overflow".to_owned()), + "should restore suppressed text before overflow", + ); assert_eq!( - action_second, + rewriter.rewrite("tail", &ctx(true, &state)), ScriptRewriteAction::Keep, - "Final chunk of fragmented script should be kept" + "should pass through until the text node ends", ); - // No payloads should be stored - post-processor will handle this - assert!( - state - .get::>(NEXTJS_INTEGRATION_ID) - .is_none(), - "No RSC state should be created for fragmented scripts" + let later = r#"self.__next_f.push([1,"later"])"#; + assert_eq!( + rewriter.rewrite(later, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should keep later RSC scripts unchanged after unsafe overflow", ); } @@ -207,4 +476,180 @@ mod tests { "Non-RSC scripts should be kept unchanged" ); } + + #[test] + fn oversized_partial_header_bypasses_later_continuation() { + for suffix in ["1", "1:", "1:T", "1:T2"] { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(Arc::new(NextJsIntegrationConfig { + max_combined_payload_bytes: 100, + ..(*test_config()).clone() + })); + let script = format!("self.__next_f.push([1,\"{}{suffix}\"])", "x".repeat(100),); + assert_eq!( + rewriter.rewrite(&script, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should pass through an oversized script", + ); + let continuation = r#"self.__next_f.push([1,"a,https://origin.example.com/path"] )"#; + assert_eq!( + rewriter.rewrite(continuation, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should preserve later payloads after oversized header prefix {suffix}", + ); + } + } + + #[test] + fn oversized_continuation_bypasses_an_unresolved_group() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(Arc::new(NextJsIntegrationConfig { + max_combined_payload_bytes: 100, + ..(*test_config()).clone() + })); + let first = r#"self.__next_f.push([1,"1:T200,start"])"#; + assert!( + matches!( + rewriter.rewrite(first, &ctx(true, &state)), + ScriptRewriteAction::Replace(_) + ), + "should capture incomplete group" + ); + let oversized = format!("self.__next_f.push([1,\"{}\"])", "x".repeat(101)); + assert_eq!( + rewriter.rewrite(&oversized, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should pass through oversized continuation" + ); + let later = r#"self.__next_f.push([1,"https://origin.example.com/path/"])"#; + assert_eq!( + rewriter.rewrite(later, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should preserve later continuation of bypassed group" + ); + } + + /// Every fragmentation of a genuine qualified push must still be captured. + /// The receiver can be split from its identifier at any byte, and the + /// verified receiver context is what lets the claim proceed. + #[test] + fn captures_qualified_push_at_every_fragment_boundary() { + for script in [ + r#"self.__next_f.push([1,"1:T3,ab"])"#, + r#"window.__next_f.push([1,"1:T3,ab"])"#, + r#";self.__next_f.push([1,"1:T3,ab"])"#, + ] { + for split in 1..script.len() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let _ = rewriter.rewrite(&script[..split], &ctx(false, &state)); + let _ = rewriter.rewrite(&script[split..], &ctx(true, &state)); + + let shared = document_state(&state); + let guard = shared.lock().expect("should lock document state"); + assert_eq!( + guard.captured_payloads.len(), + 1, + "should capture `{script}` split at byte {split}" + ); + assert_eq!( + guard.captured_payloads[0].original, "1:T3,ab", + "should capture the complete payload of `{script}` split at byte {split}" + ); + } + } + } + + /// No fragmentation may turn an unrelated publisher script into Flight data. + /// The receiver context has to reject these at every split, including the + /// splits that leave a bare `__next_f` at the start of the claim. + #[test] + fn never_captures_foreign_receiver_at_any_fragment_boundary() { + for script in [ + r#"myself.__next_f.push([1,"1:T3,ab"])"#, + r#"myAnalytics.__next_f.push([1,"1:T3,ab"])"#, + r#"foo.bar.__next_f.push([1,"1:T3,ab"])"#, + r#"window.myapp.__next_f.push([1,"1:T3,ab"])"#, + r#"a__next_f.push([1,"1:T3,ab"])"#, + // Nothing precedes the identifier, so the receiver context is empty + // rather than disqualifying: only the qualified-receiver rule rejects it. + r#"__next_f.push([1,"1:T3,ab"])"#, + r#"(myself.__next_f=self.__next_f||[]).push([1,"1:T3,ab"])"#, + ] { + for split in 1..script.len() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let first = rewriter.rewrite(&script[..split], &ctx(false, &state)); + let second = rewriter.rewrite(&script[split..], &ctx(true, &state)); + + let shared = document_state(&state); + let guard = shared.lock().expect("should lock document state"); + assert!( + guard.captured_payloads.is_empty(), + "should not claim `{script}` split at byte {split}" + ); + drop(guard); + + // The bytes must also survive unchanged across both fragments. + let mut emitted = String::new(); + for (action, source) in [(first, &script[..split]), (second, &script[split..])] { + match action { + ScriptRewriteAction::Keep => emitted.push_str(source), + ScriptRewriteAction::Replace(value) => emitted.push_str(&value), + ScriptRewriteAction::RemoveNode => {} + } + } + assert_eq!( + emitted, script, + "should stream `{script}` through unchanged when split at byte {split}" + ); + } + } + } + + /// The captured-payload queue holds parser-held script text, so it must stay + /// bounded even though the group limit no longer gates it. Payloads that each + /// fit the group limit still fall back once their total exceeds the parser's + /// script-buffer budget. + #[test] + fn queued_payloads_are_bounded_by_the_script_buffer_budget() { + // Quote-free so the JS string literal is not terminated early; this test + // is about the queue budget, not about rewriting. + let payload = "a".repeat(40); + let script = format!(r#"self.__next_f.push([1,"{payload}"])"#); + // Room for one payload, not two. + let budget = payload.len() + payload.len() / 2; + + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let context = IntegrationScriptContext { + max_buffered_script_bytes: budget, + ..ctx(true, &state) + }; + + let first = rewriter.rewrite(&script, &context); + assert!( + matches!(first, ScriptRewriteAction::Replace(ref value) if value.contains("__ts_rsc_")), + "the first payload should fit the queue budget" + ); + + let second = rewriter.rewrite(&script, &context); + assert_eq!( + second, + ScriptRewriteAction::Keep, + "the payload crossing the queue budget should stream through unchanged" + ); + + let shared = document_state(&state); + let guard = shared.lock().expect("should lock document state"); + assert_eq!( + guard.captured_payloads.len(), + 1, + "should not queue a payload past the script-buffer budget" + ); + assert!( + guard.bypass_rsc, + "crossing the queue budget should bypass the rest of the document" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs new file mode 100644 index 000000000..da0e5b971 --- /dev/null +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs @@ -0,0 +1,1306 @@ +use std::collections::VecDeque; +use std::io; +use std::sync::{Arc, Mutex}; + +use crate::integrations::{ + IntegrationDocumentState, IntegrationHtmlStreamContext, IntegrationHtmlStreamProcessorFactory, +}; +use crate::streaming_processor::StreamProcessor; + +use super::rsc::{ + DEFAULT_MAX_COMBINED_PAYLOAD_BYTES, PendingTChunk, TChunkStep, next_tchunk, + rewrite_rsc_scripts_combined_with_limit, +}; +use super::shared::RscUrlRewriter; +use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; + +pub(super) const RSC_PAYLOAD_PLACEHOLDER_PREFIX: &str = "__ts_rsc_"; +pub(super) const RSC_PAYLOAD_PLACEHOLDER_SUFFIX: &str = "__"; +pub(super) const MAX_UNRESOLVED_RSC_PAYLOADS: usize = 256; + +#[derive(Debug, Default)] +pub(super) enum FragmentState { + #[default] + Idle, + Buffering(String), + BypassUntilLast, +} + +#[derive(Debug, Clone)] +pub(super) struct CapturedPayload { + pub(super) placeholder: String, + pub(super) original: String, +} + +#[derive(Debug)] +pub(super) struct NextJsDocumentState { + pub(super) namespace: String, + pub(super) next_data: FragmentState, + pub(super) rsc_script: FragmentState, + pub(super) rsc_probe: String, + /// Tail of script text already released for the current text node, kept so a + /// `self.`/`window.` receiver that streamed before its `__next_f` identifier + /// was recognized can still be verified. + pub(super) rsc_receiver_context: String, + /// The active claim begins at a bare `__next_f` whose receiver was verified + /// from [`NextJsDocumentState::rsc_receiver_context`]. + pub(super) rsc_receiver_trimmed: bool, + pub(super) captured_payloads: VecDeque, + pub(super) captured_payload_bytes: usize, + pub(super) next_placeholder_index: usize, + pub(super) bypass_rsc: bool, +} + +impl Default for NextJsDocumentState { + fn default() -> Self { + Self { + namespace: uuid::Uuid::new_v4().simple().to_string(), + next_data: FragmentState::Idle, + rsc_script: FragmentState::Idle, + rsc_probe: String::new(), + rsc_receiver_context: String::new(), + rsc_receiver_trimmed: false, + captured_payloads: VecDeque::new(), + captured_payload_bytes: 0, + next_placeholder_index: 0, + bypass_rsc: false, + } + } +} + +pub(super) fn document_state(state: &IntegrationDocumentState) -> Arc> { + state.get_or_insert_with(NEXTJS_INTEGRATION_ID, || { + Mutex::new(NextJsDocumentState::default()) + }) +} + +pub(super) fn rsc_payload_placeholder(namespace: &str, index: usize) -> String { + format!("{RSC_PAYLOAD_PLACEHOLDER_PREFIX}{namespace}_{index}{RSC_PAYLOAD_PLACEHOLDER_SUFFIX}") +} + +pub(super) enum FragmentCapture<'a> { + CompleteBorrowed(&'a str), + CompleteOwned(String), + Suppress, + Restore(String), + PassThrough, +} + +pub(super) fn capture_fragment<'a>( + state: &mut FragmentState, + content: &'a str, + is_last: bool, + limit: usize, +) -> FragmentCapture<'a> { + match state { + FragmentState::Idle if is_last => { + if content.len() > limit { + FragmentCapture::PassThrough + } else { + FragmentCapture::CompleteBorrowed(content) + } + } + FragmentState::Idle => { + if content.len() > limit { + *state = FragmentState::BypassUntilLast; + FragmentCapture::PassThrough + } else { + *state = FragmentState::Buffering(content.to_owned()); + FragmentCapture::Suppress + } + } + FragmentState::Buffering(buffer) => { + let exceeds_limit = buffer + .len() + .checked_add(content.len()) + .is_none_or(|combined| combined > limit); + if exceeds_limit { + let mut restored = std::mem::take(buffer); + restored.push_str(content); + *state = if is_last { + FragmentState::Idle + } else { + FragmentState::BypassUntilLast + }; + FragmentCapture::Restore(restored) + } else { + buffer.push_str(content); + if is_last { + let complete = std::mem::take(buffer); + *state = FragmentState::Idle; + FragmentCapture::CompleteOwned(complete) + } else { + FragmentCapture::Suppress + } + } + } + FragmentState::BypassUntilLast => { + if is_last { + *state = FragmentState::Idle; + } + FragmentCapture::PassThrough + } + } +} + +pub(super) struct NextJsRscStreamProcessorFactory { + config: Arc, +} + +impl NextJsRscStreamProcessorFactory { + pub(super) fn new(config: Arc) -> Self { + Self { config } + } +} + +impl IntegrationHtmlStreamProcessorFactory for NextJsRscStreamProcessorFactory { + fn integration_id(&self) -> &'static str { + NEXTJS_INTEGRATION_ID + } + + fn create(&self, context: IntegrationHtmlStreamContext) -> Box { + let limit = if self.config.max_combined_payload_bytes == 0 { + DEFAULT_MAX_COMBINED_PAYLOAD_BYTES + } else { + self.config.max_combined_payload_bytes + }; + Box::new(NextJsRscStreamProcessor::new( + document_state(&context.document_state), + context.origin_host, + context.request_host, + context.request_scheme, + limit, + )) + } +} + +pub(super) struct NextJsRscStreamProcessor { + state: Arc>, + origin_host: String, + request_host: String, + request_scheme: String, + limit: usize, + pending_candidate: Vec, + held_output: Vec, + group: Vec, + classifier: RscGroupClassifier, + rewriter: RscUrlRewriter, +} + +impl NextJsRscStreamProcessor { + fn new( + state: Arc>, + origin_host: String, + request_host: String, + request_scheme: String, + limit: usize, + ) -> Self { + Self { + state, + origin_host, + request_host, + request_scheme, + limit, + pending_candidate: Vec::new(), + held_output: Vec::new(), + group: Vec::new(), + classifier: RscGroupClassifier::new(limit), + rewriter: RscUrlRewriter::new(), + } + } + + fn namespace_prefix(&self) -> Vec { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + format!("{RSC_PAYLOAD_PLACEHOLDER_PREFIX}{}_", state.namespace).into_bytes() + } + + fn next_captured(&self) -> Option { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .captured_payloads + .front() + .cloned() + } + + fn pop_captured(&self, placeholder: &str) -> io::Result { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(payload) = state.captured_payloads.pop_front() else { + return Err(io::Error::other( + "Next.js RSC placeholder has no captured payload", + )); + }; + if payload.placeholder != placeholder { + state.captured_payloads.push_front(payload); + return Err(io::Error::other( + "Next.js RSC placeholders are out of document order", + )); + } + Ok(payload) + } + + fn append_held(&mut self, bytes: &[u8]) -> bool { + if self + .held_output + .len() + .checked_add(bytes.len()) + .is_none_or(|combined| combined > self.limit) + { + false + } else { + self.held_output.extend_from_slice(bytes); + true + } + } + + fn release_group(&mut self, rewritten: Option<&[String]>) -> io::Result> { + let released_payload_bytes = self + .group + .iter() + .map(|payload| payload.original.len()) + .sum::(); + let replacements: Vec<&str> = match &rewritten { + Some(rewritten) => rewritten.iter().map(String::as_str).collect(), + None => self + .group + .iter() + .map(|payload| payload.original.as_str()) + .collect(), + }; + let held_output = std::mem::take(&mut self.held_output); + let output = substitute_payloads( + &held_output, + &self.group, + &replacements, + &self.namespace_prefix(), + )?; + self.group.clear(); + self.classifier.reset(); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.captured_payload_bytes = state + .captured_payload_bytes + .saturating_sub(released_payload_bytes); + Ok(output) + } + + fn resolve_group(&mut self) -> io::Result>> { + // Classification is incremental, but each completed chunk still costs a + // segment inspection and a boundary check, so bound the segment count; + // the hydration-safe fallback restores originals. + if self.group.len() > MAX_UNRESOLVED_RSC_PAYLOADS { + log::warn!( + "Next.js RSC fallback: segment limit, {} payloads", + self.group.len() + ); + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bypass_rsc = true; + return self.release_group(None).map(Some); + } + let payload = self + .group + .last() + .expect("should resolve a group only after a payload joined it"); + let status = self.classifier.push(&payload.original); + self.resolve_status(status) + } + + /// Act on a group status: release the group, or hold for more payloads. + fn resolve_status(&mut self, status: RscGroupStatus) -> io::Result>> { + match status { + RscGroupStatus::NeedMore => Ok(None), + RscGroupStatus::CompleteRewritable => { + let payloads: Vec<&str> = self + .group + .iter() + .map(|payload| payload.original.as_str()) + .collect(); + let rewritten = rewrite_rsc_scripts_combined_with_limit( + &payloads, + &self.rewriter, + &self.origin_host, + &self.request_host, + &self.request_scheme, + self.limit, + ); + if rewritten.len() != self.group.len() { + log::warn!( + "Next.js RSC fallback: rewrite count mismatch, {} payloads", + self.group.len() + ); + return self.release_group(None).map(Some); + } + log::debug!("Next.js RSC group completes: {} payloads", self.group.len()); + self.release_group(Some(&rewritten)).map(Some) + } + RscGroupStatus::CompleteUnrewritable => { + log::warn!( + "Next.js RSC fallback: split header, {} payloads", + self.group.len() + ); + self.release_group(None).map(Some) + } + RscGroupStatus::Invalid => { + log::warn!( + "Next.js RSC fallback: invalid group, {} payloads", + self.group.len() + ); + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bypass_rsc = true; + self.release_group(None).map(Some) + } + } + } + + /// Restore every captured payload and hand back the bytes unchanged. + /// + /// Draining the whole queue is safe because capture and placeholder emission + /// are atomic: `NextJsRscPlaceholderRewriter::rewrite_complete` pushes a + /// payload and returns the script carrying its placeholder in the same call, + /// so a queued payload's placeholder is always already in the held output or + /// in `current`. A queued payload whose placeholder had not yet been emitted + /// would fail substitution rather than degrade to unchanged bytes. + fn release_bypass(&mut self, current: &[u8]) -> io::Result> { + if !self.group.is_empty() || self.next_captured().is_some() { + log::warn!( + "Next.js RSC fallback: capture or output limit, {} held payloads", + self.group.len() + ); + } + let mut output = self.release_group(None)?; + output.extend_from_slice(&self.pending_candidate); + self.pending_candidate.clear(); + let captured = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.bypass_rsc = true; + state.captured_payload_bytes = 0; + state.captured_payloads.drain(..).collect::>() + }; + let replacements: Vec<&str> = captured + .iter() + .map(|payload| payload.original.as_str()) + .collect(); + output.extend(substitute_payloads( + current, + &captured, + &replacements, + &self.namespace_prefix(), + )?); + Ok(output) + } +} + +impl StreamProcessor for NextJsRscStreamProcessor { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> io::Result> { + let mut current = std::mem::take(&mut self.pending_candidate); + current.extend_from_slice(chunk); + + let bypass = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bypass_rsc; + if bypass { + return self.release_bypass(¤t); + } + + let namespace_prefix = self.namespace_prefix(); + let mut output = Vec::new(); + let mut cursor = 0; + loop { + let Some(expected) = self.next_captured() else { + let remainder = ¤t[cursor..]; + if self.group.is_empty() { + output.extend_from_slice(remainder); + } else if !self.append_held(remainder) { + output.extend(self.release_bypass(remainder)?); + } + break; + }; + + let remainder = ¤t[cursor..]; + let Some(relative_start) = find_bytes(remainder, &namespace_prefix) else { + let retained = longest_suffix_prefix(remainder, expected.placeholder.as_bytes()); + let ready_end = remainder.len() - retained; + let ready = &remainder[..ready_end]; + if self.group.is_empty() { + output.extend_from_slice(ready); + } else if !self.append_held(ready) { + output.extend(self.release_bypass(remainder)?); + break; + } + self.pending_candidate + .extend_from_slice(&remainder[ready_end..]); + break; + }; + + let placeholder_start = cursor + relative_start; + let before = ¤t[cursor..placeholder_start]; + if self.group.is_empty() { + output.extend_from_slice(before); + } else if !self.append_held(before) { + output.extend(self.release_bypass(¤t[cursor..])?); + break; + } + + let placeholder = expected.placeholder.as_bytes(); + let available = ¤t[placeholder_start..]; + if available.len() < placeholder.len() && placeholder.starts_with(available) { + self.pending_candidate.extend_from_slice(available); + break; + } + if !available.starts_with(placeholder) { + return Err(io::Error::other( + "Next.js RSC output contains an unknown generated placeholder", + )); + } + if !self.append_held(placeholder) { + output.extend(self.release_bypass(¤t[placeholder_start..])?); + break; + } + let captured = self.pop_captured(&expected.placeholder)?; + self.group.push(captured); + cursor = placeholder_start + placeholder.len(); + + if let Some(released) = self.resolve_group()? { + output.extend(released); + } + if self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bypass_rsc + { + output.extend(self.release_bypass(¤t[cursor..])?); + break; + } + } + + if is_last { + if !self.pending_candidate.is_empty() { + if self.group.is_empty() { + output.append(&mut self.pending_candidate); + } else { + let pending = std::mem::take(&mut self.pending_candidate); + if !self.append_held(&pending) { + output.extend(self.release_bypass(&pending)?); + } + } + } + if !self.group.is_empty() { + // No further payload can arrive, so reclassify with nothing + // held back before giving up on the group. + let status = self.classifier.finalize(); + if matches!(status, RscGroupStatus::CompleteRewritable) { + if let Some(released) = self.resolve_status(status)? { + output.extend(released); + } + } else { + log::warn!( + "Next.js RSC fallback: incomplete group at EOF, {} payloads", + self.group.len() + ); + output.extend(self.release_group(None)?); + } + } + if self.next_captured().is_some() { + return Err(io::Error::other( + "Next.js RSC captured payload was not present in parser output", + )); + } + } + + Ok(output) + } +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + (!needle.is_empty()) + .then(|| { + haystack + .windows(needle.len()) + .position(|window| window == needle) + }) + .flatten() +} + +fn longest_suffix_prefix(bytes: &[u8], pattern: &[u8]) -> usize { + let maximum = bytes.len().min(pattern.len().saturating_sub(1)); + (1..=maximum) + .rev() + .find(|length| bytes.ends_with(&pattern[..*length])) + .unwrap_or(0) +} + +fn substitute_payloads( + input: &[u8], + payloads: &[CapturedPayload], + replacements: &[&str], + namespace_prefix: &[u8], +) -> io::Result> { + if payloads.len() != replacements.len() { + return Err(io::Error::other( + "Next.js RSC substitution received mismatched payloads", + )); + } + let mut output = Vec::with_capacity(input.len()); + let mut cursor = 0; + for (payload, replacement) in payloads.iter().zip(replacements) { + let placeholder = payload.placeholder.as_bytes(); + let Some(relative_position) = find_bytes(&input[cursor..], placeholder) else { + return Err(io::Error::other( + "Next.js RSC captured placeholder is missing from held output", + )); + }; + let position = cursor + relative_position; + output.extend_from_slice(&input[cursor..position]); + output.extend_from_slice(replacement.as_bytes()); + cursor = position + placeholder.len(); + } + output.extend_from_slice(&input[cursor..]); + if find_bytes(&output, namespace_prefix).is_some() { + return Err(io::Error::other( + "Next.js RSC generated placeholder remained after substitution", + )); + } + Ok(output) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RscGroupStatus { + CompleteRewritable, + CompleteUnrewritable, + NeedMore, + Invalid, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HeaderSuffixStatus { + Complete, + NeedMore, + Invalid, +} + +/// Incremental classifier for one logical RSC group. +/// +/// Payloads are appended once and scanned once: a chunk whose content has not +/// fully arrived is resumed where consumption stopped, and the trailing +/// non-chunk segment is inspected from where inspection stopped. Re-deriving +/// the whole group on every payload made a large but permitted response cost +/// CPU quadratic in its segment count. +pub(super) struct RscGroupClassifier { + max_combined_payload_bytes: usize, + combined: String, + /// Offsets in [`Self::combined`] where one payload meets the next. + boundaries: Vec, + /// A completed chunk's header straddled a payload boundary, so the group + /// cannot be rewritten even once it completes. + header_split: bool, + /// Where the next chunk-header search begins. + scan_from: usize, + /// A chunk whose declared content is still incomplete. + pending: Option, + /// Start of the trailing non-chunk segment. + segment_start: usize, + inspector: SegmentInspector, + invalid: bool, +} + +impl RscGroupClassifier { + pub(super) fn new(max_combined_payload_bytes: usize) -> Self { + Self { + max_combined_payload_bytes, + combined: String::new(), + boundaries: Vec::new(), + header_split: false, + scan_from: 0, + pending: None, + segment_start: 0, + inspector: SegmentInspector::default(), + invalid: false, + } + } + + /// Append the next payload of the group and reclassify. + pub(super) fn push(&mut self, payload: &str) -> RscGroupStatus { + if self.invalid { + return RscGroupStatus::Invalid; + } + let exceeds_limit = self + .combined + .len() + .checked_add(payload.len()) + .is_none_or(|total| total > self.max_combined_payload_bytes); + if exceeds_limit { + self.invalid = true; + return RscGroupStatus::Invalid; + } + if !self.combined.is_empty() { + self.boundaries.push(self.combined.len()); + } + self.combined.push_str(payload); + self.advance(false) + } + + /// Reclassify knowing no further payload can arrive, so no trailing escape + /// needs to be held back. + pub(super) fn finalize(&mut self) -> RscGroupStatus { + self.advance(true) + } + + /// Drop all group state, ready for the next group. + pub(super) fn reset(&mut self) { + self.combined.clear(); + self.boundaries.clear(); + self.header_split = false; + self.scan_from = 0; + self.pending = None; + self.segment_start = 0; + self.inspector = SegmentInspector::default(); + self.invalid = false; + } + + fn advance(&mut self, finalize: bool) -> RscGroupStatus { + if self.invalid { + return RscGroupStatus::Invalid; + } + loop { + let step = next_tchunk( + &self.combined, + self.scan_from, + self.pending.take(), + None, + !finalize, + ); + match step { + TChunkStep::Found(chunk) => { + // Text between chunks is final once the chunk after it + // completes, so it is inspected exactly once. + let mut settled = SegmentInspector::default(); + if settled.inspect(&self.combined[self.segment_start..chunk.match_start], false) + == HeaderSuffixStatus::Invalid + { + self.invalid = true; + return RscGroupStatus::Invalid; + } + if self.boundaries.iter().any(|boundary| { + chunk.match_start < *boundary && *boundary < chunk.header_end + }) { + self.header_split = true; + } + self.segment_start = chunk.content_end; + self.scan_from = chunk.content_end; + self.inspector = SegmentInspector::default(); + } + TChunkStep::Pending(chunk) => { + self.pending = Some(chunk); + return RscGroupStatus::NeedMore; + } + TChunkStep::Exhausted => break, + TChunkStep::Invalid => { + self.invalid = true; + return RscGroupStatus::Invalid; + } + } + } + + match self + .inspector + .inspect(&self.combined[self.segment_start..], true) + { + HeaderSuffixStatus::Complete => { + if self.header_split { + RscGroupStatus::CompleteUnrewritable + } else { + RscGroupStatus::CompleteRewritable + } + } + HeaderSuffixStatus::NeedMore => { + // A header can only start inside the pending candidate, so the + // next search resumes there rather than at the scanned end. + // + // This is the one step still proportional to accumulated bytes + // rather than to the new payload: a group that is one long hex + // run keeps the candidate at its start, so the header search + // re-scans it. That search is a literal prefilter bounded by + // `max_combined_payload_bytes` (~13ms over 4MiB in 256 + // payloads), unlike the escape walk this classifier replaced. + self.scan_from = self.segment_start + self.inspector.partial_header_start(); + RscGroupStatus::NeedMore + } + HeaderSuffixStatus::Invalid => { + self.invalid = true; + RscGroupStatus::Invalid + } + } + } +} + +/// Classify a complete group in one call. +pub(super) fn classify_rsc_group( + payloads: &[&str], + max_combined_payload_bytes: usize, +) -> RscGroupStatus { + let mut classifier = RscGroupClassifier::new(max_combined_payload_bytes); + for payload in payloads { + classifier.push(payload); + } + classifier.finalize() +} + +/// Resumable inspection of text outside T-chunk content. +/// +/// Retains how far the text has been proven free of an incomplete +/// `id:Tlength,` header so growing text is not re-inspected from the start. +#[derive(Debug, Clone, Copy, Default)] +struct SegmentInspector { + /// Where the next inspection resumes. + index: usize, + /// How far the hex run starting at [`Self::index`] has been verified. + run_cursor: usize, +} + +impl SegmentInspector { + /// Offset at which a partial header could still begin. + fn partial_header_start(&self) -> usize { + self.index + } + + fn inspect(&mut self, segment: &str, terminal: bool) -> HeaderSuffixStatus { + let bytes = segment.as_bytes(); + + while self.index < bytes.len() { + if !bytes[self.index].is_ascii_hexdigit() + || self.index > 0 && bytes[self.index - 1].is_ascii_hexdigit() + { + self.advance_to(self.index + 1); + continue; + } + + let mut cursor = self.run_cursor.max(self.index); + while cursor < bytes.len() && bytes[cursor].is_ascii_hexdigit() { + cursor += 1; + } + if cursor == bytes.len() { + // The run may still grow, so keep its verified extent. + self.run_cursor = cursor; + return if terminal { + HeaderSuffixStatus::NeedMore + } else { + HeaderSuffixStatus::Complete + }; + } + if terminal && &bytes[cursor..] == b":" { + return HeaderSuffixStatus::NeedMore; + } + if bytes.get(cursor..cursor + 2) != Some(b":T") { + self.advance_to(cursor + 1); + continue; + } + + cursor += 2; + if cursor == bytes.len() { + return if terminal { + HeaderSuffixStatus::NeedMore + } else { + HeaderSuffixStatus::Invalid + }; + } + if !bytes[cursor].is_ascii_hexdigit() { + return HeaderSuffixStatus::Invalid; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_hexdigit() { + cursor += 1; + } + if cursor == bytes.len() { + return if terminal { + HeaderSuffixStatus::NeedMore + } else { + HeaderSuffixStatus::Invalid + }; + } + if bytes[cursor] != b',' { + return HeaderSuffixStatus::Invalid; + } + + self.advance_to(cursor + 1); + } + + HeaderSuffixStatus::Complete + } + + fn advance_to(&mut self, index: usize) { + self.index = index; + self.run_cursor = index; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_complete_header_with_cross_payload_content_as_rewritable() { + let payloads = ["1a:T3,ab", "c\n"]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteRewritable, + "should rewrite a complete header whose content crosses payloads", + ); + } + + #[test] + fn classifies_header_split_across_payloads_as_complete_unrewritable() { + let payloads = ["1a:T", "3,abc\n"]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteUnrewritable, + "should restore a physically split header unchanged", + ); + } + + #[test] + fn classifies_every_header_split_as_unrewritable_after_completion() { + let header = "1a:T3e,"; + for split in 1..header.len() { + let first = &header[..split]; + let second = format!("{}{}", &header[split..], "x".repeat(0x3e)); + let payloads = [first, second.as_str()]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteUnrewritable, + "should restore a header split at byte {split}", + ); + } + } + + #[test] + fn classifies_incomplete_content_and_header_candidates_as_needing_more() { + for payloads in [vec!["1a:T3,ab"], vec!["prefix1a:T"], vec!["prefix1a"]] { + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::NeedMore, + "should retain an incomplete T-chunk candidate", + ); + } + } + + #[test] + fn retains_every_incomplete_header_prefix() { + let header = "1a:T3e,"; + for split in 1..header.len() { + assert_eq!( + classify_rsc_group(&[&header[..split]], usize::MAX), + RscGroupStatus::NeedMore, + "should retain the header prefix split at byte {split}", + ); + } + } + + #[test] + fn classifies_disproved_hex_suffix_as_complete() { + let payloads = ["ordinary1a", "-suffix"]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteRewritable, + "should release a trailing hexadecimal run once disproved", + ); + } + + #[test] + fn classifies_malformed_and_unreasonable_lengths_as_invalid() { + for payload in ["1a:Tzz,value", "1a:T6400001,value"] { + assert_eq!( + classify_rsc_group(&[payload], usize::MAX), + RscGroupStatus::Invalid, + "should reject malformed or unreasonable T-chunk lengths", + ); + } + } + + #[test] + fn counts_javascript_escapes_and_unicode_bytes() { + for payload in [r#"1:T3,a\n\""#, r#"1:T4,\ud83d\ude00"#, "1:T3,€"] { + assert_eq!( + classify_rsc_group(&[payload], usize::MAX), + RscGroupStatus::CompleteRewritable, + "should count decoded JavaScript string bytes", + ); + } + } + + #[test] + fn classifies_multiple_complete_tchunks() { + let payloads = ["1:T1,a2:T2,bc"]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteRewritable, + "should accept multiple complete T-chunks", + ); + } + + #[test] + fn rejects_payloads_over_the_group_bound_before_combining() { + let payloads = ["1:T1,a", "tail"]; + + assert_eq!( + classify_rsc_group(&payloads, 4), + RscGroupStatus::Invalid, + "should reject a group larger than its configured bound", + ); + } + + fn processor_with_payloads( + payloads: &[&str], + limit: usize, + ) -> (NextJsRscStreamProcessor, Vec) { + let integration_state = IntegrationDocumentState::default(); + let shared = document_state(&integration_state); + let mut placeholders = Vec::new(); + { + let mut state = shared.lock().expect("should lock document state"); + for payload in payloads { + let placeholder = + rsc_payload_placeholder(&state.namespace, state.next_placeholder_index); + state.next_placeholder_index += 1; + state.captured_payload_bytes += payload.len(); + state.captured_payloads.push_back(CapturedPayload { + placeholder: placeholder.clone(), + original: (*payload).to_owned(), + }); + placeholders.push(placeholder); + } + } + ( + NextJsRscStreamProcessor::new( + shared, + "origin.example.com".to_owned(), + "proxy.example.com".to_owned(), + "https".to_owned(), + limit, + ), + placeholders, + ) + } + + #[test] + fn stream_processor_emits_ordinary_html_before_eof() { + let (mut processor, _) = processor_with_payloads(&[], 1024); + + assert_eq!( + processor + .process_chunk(b"ordinary", false) + .expect("should process ordinary HTML"), + b"ordinary", + "should not wait for EOF without an unresolved RSC group", + ); + } + + #[test] + fn stream_processor_restores_header_split_after_colon() { + let payloads = ["1:", "T25,https://origin.example.com/longer-path!"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + let first = processor + .process_chunk(placeholders[0].as_bytes(), false) + .expect("should retain partial header"); + assert!(first.is_empty(), "should wait for the rest of the header"); + + let second = processor + .process_chunk(placeholders[1].as_bytes(), true) + .expect("should restore a physically split header"); + assert_eq!( + second, + payloads.concat().as_bytes(), + "should preserve URL and declared length together", + ); + } + + #[test] + fn stream_processor_restores_payloads_on_rewrite_count_mismatch() { + let payloads = ["1:T3,ab", "c\n\0SPLIT\0https://origin.example.com/path/"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + let output = processor + .process_chunk(placeholders.concat().as_bytes(), true) + .expect("should restore originals when the rewrite count differs"); + assert_eq!( + output, + payloads.concat().as_bytes(), + "should preserve all original bytes" + ); + } + + #[test] + fn stream_processor_rewrites_and_releases_a_complete_payload_in_one_call() { + let payload = r#"1:T29,{"url":"https://origin.example.com/path"}"#; + let (mut processor, placeholders) = processor_with_payloads(&[payload], 1024); + let input = format!("before{}after", placeholders[0]); + + let output = processor + .process_chunk(input.as_bytes(), false) + .expect("should process complete RSC payload"); + let output = String::from_utf8(output).expect("should emit UTF-8 HTML"); + + assert!(output.starts_with("before")); + assert!(output.ends_with("after")); + assert!(output.contains("proxy.example.com/path")); + assert!(!output.contains(RSC_PAYLOAD_PLACEHOLDER_PREFIX)); + } + + #[test] + fn stream_processor_holds_only_until_cross_payload_content_completes() { + let payloads = ["1:T3,ab", "c"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + + let first = processor + .process_chunk(format!("head{}middle", placeholders[0]).as_bytes(), false) + .expect("should process incomplete group"); + assert_eq!(first, b"head", "should hold from the first placeholder"); + + let second = processor + .process_chunk(format!("{}tail", placeholders[1]).as_bytes(), false) + .expect("should complete group"); + assert_eq!( + second, + format!("{}middle{}tail", payloads[0], payloads[1]).as_bytes(), + "should release the complete group and interstitial output in order", + ); + } + + #[test] + fn held_output_overflow_restores_interstitial_bytes_before_the_next_payload() { + let payloads = ["1:T3,ab", "c"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 80); + + assert!( + processor + .process_chunk(placeholders[0].as_bytes(), false) + .expect("should hold incomplete group") + .is_empty() + ); + let interstitial = "x".repeat(50); + let output = processor + .process_chunk( + format!("{interstitial}{}tail", placeholders[1]).as_bytes(), + false, + ) + .expect("should restore over-limit group"); + assert_eq!( + output, + format!("{}{interstitial}{}tail", payloads[0], payloads[1]).as_bytes(), + "overflow fallback must preserve bytes between payload scripts" + ); + } + + #[test] + fn invalid_group_restores_later_payloads_in_the_same_output_chunk() { + let payloads = [ + "1:Tzz,invalid", + r#"1:T29,{"url":"https://origin.example.com/path"}"#, + ]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + let input = format!("{}middle{}tail", placeholders[0], placeholders[1]); + + let output = processor + .process_chunk(input.as_bytes(), false) + .expect("should restore invalid group and later payload"); + assert_eq!( + output, + format!("{}middle{}tail", payloads[0], payloads[1]).as_bytes(), + "document-wide bypass must take effect within the current output chunk" + ); + } + + #[test] + fn stream_processor_matches_a_placeholder_split_across_output_chunks() { + let (mut processor, placeholders) = processor_with_payloads(&["plain"], 1024); + let placeholder = &placeholders[0]; + let split = placeholder.len() / 2; + + let first = processor + .process_chunk(&placeholder.as_bytes()[..split], false) + .expect("should retain a partial placeholder"); + assert!(first.is_empty(), "should retain only the candidate suffix"); + let second = processor + .process_chunk(&placeholder.as_bytes()[split..], false) + .expect("should finish the placeholder"); + assert_eq!(second, b"plain", "should restore the captured payload"); + } + + #[test] + fn unresolved_group_bytes_remain_charged_until_release() { + let payloads = ["1:T3,ab", "c"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + let state = Arc::clone(&processor.state); + + assert!( + processor + .process_chunk(placeholders[0].as_bytes(), false) + .expect("should hold incomplete group") + .is_empty() + ); + assert_eq!( + state + .lock() + .expect("should lock document state") + .captured_payload_bytes, + payloads.iter().map(|payload| payload.len()).sum::(), + "held payloads must remain in the request-scoped capture budget" + ); + + processor + .process_chunk(placeholders[1].as_bytes(), false) + .expect("should release complete group"); + assert_eq!( + state + .lock() + .expect("should lock document state") + .captured_payload_bytes, + 0, + "released payloads should return their request-scoped budget" + ); + } + + #[test] + fn excessive_unresolved_payload_count_falls_back_unchanged() { + let payloads = vec!["1:Tffff,x"; MAX_UNRESOLVED_RSC_PAYLOADS + 1]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, usize::MAX); + let input = placeholders.join(""); + + let output = processor + .process_chunk(input.as_bytes(), false) + .expect("should restore excessive unresolved group"); + assert_eq!(output, payloads.join("").as_bytes()); + assert!( + processor + .state + .lock() + .expect("should lock document state") + .bypass_rsc, + "excessive segment count should enable document-wide fallback" + ); + } + + /// A T-chunk spread over many payloads must classify the same whether it is + /// fed incrementally or all at once. Feeding it incrementally is what keeps + /// the cost linear in the group's bytes instead of bytes times segments. + #[test] + fn incremental_classification_matches_whole_group_classification() { + // Each repetition unescapes to `ab` + newline + `cd` + `A` = 6 bytes. + let repetitions = 64; + let content = r"ab\ncd\x41".repeat(repetitions); + let declared = repetitions * 6; + let document = format!("1:T{declared:x},{content}\n"); + + for segments in [2usize, 7, 64] { + let per = document.len().div_ceil(segments); + let payloads: Vec<&str> = document + .as_bytes() + .chunks(per) + .map(|chunk| std::str::from_utf8(chunk).expect("fixture should be ASCII")) + .collect(); + + let mut classifier = RscGroupClassifier::new(usize::MAX); + for payload in &payloads { + classifier.push(payload); + } + let incremental = classifier.finalize(); + + assert_eq!( + incremental, + classify_rsc_group(&payloads, usize::MAX), + "incremental classification of {segments} segments should match the whole group" + ); + assert_eq!( + incremental, + RscGroupStatus::CompleteRewritable, + "a complete T-chunk split into {segments} segments should stay rewritable" + ); + } + } + + /// Classification must not depend on how a group was split, except for the + /// one rule that is defined in terms of splits: a header straddling a + /// payload boundary is complete but unrewritable. + #[test] + fn classification_is_independent_of_payload_split() { + // Each fixture lists the byte span of every `id:Tlength,` header in it. + for (document, headers) in [ + (r"1:T6,ab\ncdx", &[(0usize, 5usize)][..]), + (r"1:T6,ab\ncdx\nplain text", &[(0, 5)][..]), + (r"1:T6,ab\ncdx\ndead", &[(0, 5)][..]), + (r"1:T6,ab\ncdx2:T2,zz", &[(0, 5), (12, 17)][..]), + ("plain text with no chunk", &[][..]), + ("trailing hex dead", &[][..]), + ("5:T", &[][..]), + ] { + let whole = classify_rsc_group(&[document], usize::MAX); + + for split in 1..document.len() { + let parts = [&document[..split], &document[split..]]; + let actual = classify_rsc_group(&parts, usize::MAX); + // A straddling header only downgrades a group that is otherwise + // rewritable; an incomplete group stays incomplete. + let straddles = headers + .iter() + .any(|(start, end)| *start < split && split < *end); + let expected = match whole { + RscGroupStatus::CompleteRewritable if straddles => { + RscGroupStatus::CompleteUnrewritable + } + other => other, + }; + assert_eq!( + actual, expected, + "`{document}` split at byte {split} should classify as {expected:?}" + ); + } + } + } + + /// The byte limit must trip at the same accumulation point whether payloads + /// are fed one at a time or classified as a whole group. + #[test] + fn incremental_classification_honors_the_byte_limit() { + let first = "1:T6,ab"; + let second = r"\ncdx"; + // One byte short of holding both payloads. + let limit = first.len() + second.len() - 1; + + let mut classifier = RscGroupClassifier::new(limit); + assert_eq!( + classifier.push(first), + RscGroupStatus::NeedMore, + "a payload within the limit should await the rest of its chunk" + ); + assert_eq!( + classifier.push(second), + RscGroupStatus::Invalid, + "the payload that crosses the limit should invalidate the group" + ); + assert_eq!( + classify_rsc_group(&[first, second], limit), + RscGroupStatus::Invalid, + "whole-group classification should reach the same verdict" + ); + } +} diff --git a/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs b/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs index fb54ce1b3..b6de84449 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use error_stack::Report; use regex::{Regex, escape}; @@ -8,20 +8,13 @@ use crate::integrations::{ IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction, }; +use super::rsc_stream::{FragmentCapture, capture_fragment, document_state}; use super::shared::strip_origin_host_with_optional_port; use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; pub(super) struct NextJsNextDataRewriter { config: Arc, rewriter: UrlRewriter, - /// Accumulates text fragments when `lol_html` splits a text node across - /// chunk boundaries. Drained on `is_last_in_text_node`. - /// - /// Uses `Mutex` to satisfy the `Sync` bound on `IntegrationScriptRewriter`. - /// The pipeline is single-threaded (`lol_html::HtmlRewriter` is `!Send`), - /// so the lock is uncontended. `lol_html` delivers text chunks sequentially - /// per element — the buffer is always empty when a new element's text begins. - accumulated_text: Mutex, } impl NextJsNextDataRewriter { @@ -31,7 +24,6 @@ impl NextJsNextDataRewriter { Ok(Self { rewriter: UrlRewriter::new(&config.rewrite_attributes)?, config, - accumulated_text: Mutex::new(String::new()), }) } @@ -74,33 +66,30 @@ impl IntegrationScriptRewriter for NextJsNextDataRewriter { return ScriptRewriteAction::keep(); } - let mut buf = self - .accumulated_text + let state = document_state(ctx.document_state); + let mut state = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if !ctx.is_last_in_text_node { - // Intermediate fragment — accumulate and suppress output. - buf.push_str(content); - return ScriptRewriteAction::RemoveNode; - } - - // Last fragment. If nothing was accumulated, process directly. - if buf.is_empty() { - return self.rewrite_structured(content, ctx); - } - - // Complete the accumulated text and process the full content. - // If rewrite_structured returns Keep, we must still emit the full - // accumulated text via Replace — intermediate fragments were already - // removed from lol_html's output via RemoveNode. - buf.push_str(content); - let full_content = std::mem::take(&mut *buf); - let action = self.rewrite_structured(&full_content, ctx); - if matches!(action, ScriptRewriteAction::Keep) { - return ScriptRewriteAction::replace(full_content); + match capture_fragment( + &mut state.next_data, + content, + ctx.is_last_in_text_node, + ctx.max_buffered_script_bytes, + ) { + FragmentCapture::CompleteBorrowed(complete) => self.rewrite_structured(complete, ctx), + FragmentCapture::CompleteOwned(complete) => { + let action = self.rewrite_structured(&complete, ctx); + if matches!(action, ScriptRewriteAction::Keep) { + ScriptRewriteAction::replace(complete) + } else { + action + } + } + FragmentCapture::Suppress => ScriptRewriteAction::RemoveNode, + FragmentCapture::Restore(content) => ScriptRewriteAction::replace(content), + FragmentCapture::PassThrough => ScriptRewriteAction::Keep, } - action } } @@ -240,6 +229,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: true, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state, } } @@ -514,6 +504,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { @@ -560,6 +551,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: true, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; @@ -590,6 +582,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { @@ -614,4 +607,91 @@ mod tests { other => panic!("expected Replace with passthrough, got {other:?}"), } } + + #[test] + fn fragmented_next_data_releases_suppressed_prefix_on_overflow_and_resets() { + let rewriter = NextJsNextDataRewriter::new(test_config()).expect("should build rewriter"); + let document_state = IntegrationDocumentState::default(); + let first = IntegrationScriptContext { + selector: "script#__NEXT_DATA__", + request_host: "ts.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + is_last_in_text_node: false, + max_buffered_script_bytes: 8, + document_state: &document_state, + }; + + assert_eq!( + rewriter.rewrite("prefix", &first), + ScriptRewriteAction::RemoveNode, + "should suppress a bounded prefix", + ); + assert_eq!( + rewriter.rewrite("-overflow", &first), + ScriptRewriteAction::Replace("prefix-overflow".to_owned()), + "should restore the prefix before crossing the limit", + ); + assert_eq!( + rewriter.rewrite( + "-tail", + &IntegrationScriptContext { + is_last_in_text_node: true, + ..first + }, + ), + ScriptRewriteAction::Keep, + "should pass through the rest of an overflowing script", + ); + assert_eq!( + rewriter.rewrite("small", &first), + ScriptRewriteAction::RemoveNode, + "should reset for the next script", + ); + } + + #[test] + fn next_data_fragment_state_is_isolated_between_documents() { + let rewriter = NextJsNextDataRewriter::new(test_config()).expect("should build rewriter"); + let first_state = IntegrationDocumentState::default(); + let second_state = IntegrationDocumentState::default(); + let first_context = IntegrationScriptContext { + selector: "script#__NEXT_DATA__", + request_host: "ts.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + is_last_in_text_node: false, + max_buffered_script_bytes: 64, + document_state: &first_state, + }; + let second_context = IntegrationScriptContext { + document_state: &second_state, + ..first_context + }; + + assert_eq!( + rewriter.rewrite("first-", &first_context), + ScriptRewriteAction::RemoveNode, + "should buffer the first document", + ); + assert_eq!( + rewriter.rewrite("second-", &second_context), + ScriptRewriteAction::RemoveNode, + "should buffer the second document independently", + ); + + let ScriptRewriteAction::Replace(first_output) = rewriter.rewrite( + "done", + &IntegrationScriptContext { + is_last_in_text_node: true, + ..first_context + }, + ) else { + panic!("should restore the first document"); + }; + assert_eq!( + first_output, "first-done", + "should not combine request state" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/nextjs/shared.rs b/crates/trusted-server-core/src/integrations/nextjs/shared.rs index 7b88aa0ae..6d930c047 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/shared.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/shared.rs @@ -13,19 +13,83 @@ use crate::host_rewrite::rewrite_bare_host_at_boundaries; // intentionally remain lazy statics instead of participating in // `Settings::prepare_runtime`. /// RSC push script call pattern for extracting payload string boundaries. +/// +/// The `self.`/`window.` receiver is required. A fragmented script keeps its +/// receiver out of the output stream via [`RSC_RECEIVER_CANDIDATES`] instead of +/// relaxing this pattern, because an unqualified `__next_f.push([1,"…"])` +/// cannot be distinguished from an unrelated publisher script that happens to +/// own a property of the same name. pub(crate) static RSC_PUSH_CALL_PATTERN: LazyLock = LazyLock::new(|| { Regex::new( - r#"(?s)(?:(?:self|window)\.__next_f\.push|\(\s*(?:self|window)\.__next_f\s*=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.push)\(\[\s*1\s*,\s*(['"])"#, + r#"(?s)(?:(?:self|window)\.__next_f\.push|(?:\(\s*)?(?:self|window)\.__next_f\s*=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.push)\(\[\s*1\s*,\s*(['"])"#, ) .expect("valid RSC push call regex") }); +/// RSC push call pattern for a claim whose receiver already streamed. +/// +/// Anchored to the start of the claimed fragment and only usable once the +/// receiver has been verified out of band by +/// [`receiver_context_is_flight_push`], so it cannot widen what an +/// unfragmented script is allowed to match. +pub(crate) static RSC_PUSH_CALL_PATTERN_TRIMMED: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?s)^__next_f(?:\.push|\s*=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.push)\(\[\s*1\s*,\s*(['"])"#, + ) + .expect("valid trimmed RSC push call regex") +}); + +/// Longest receiver context worth retaining: `(window.` plus one boundary byte. +pub(crate) const RSC_RECEIVER_CONTEXT_BYTES: usize = 9; + +/// Whether text preceding a bare `__next_f` proves a Next.js Flight receiver. +/// +/// `context` is the tail of the script text already released for the current +/// text node. An empty context is *not* accepted: a script that opens with an +/// unqualified `__next_f.push` is not something Next.js emits, and accepting it +/// would let any global of that name be rewritten. +pub(crate) fn receiver_context_is_flight_push(context: &str) -> bool { + ["self.", "window."].iter().any(|receiver| { + context.strip_suffix(receiver).is_some_and(|leading| { + leading + .as_bytes() + .last() + .is_none_or(|byte| !is_receiver_continuation(*byte)) + }) + }) +} + +/// Characters that would make a matched receiver the tail of a longer member +/// expression or identifier, as in `myself.__next_f` or `foo.window.__next_f`. +fn is_receiver_continuation(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$' | b'.') +} + /// Find the payload string boundaries within an RSC push script. /// /// Returns `Some((start, end))` where `start` is the position after the opening quote /// and `end` is the position of the closing quote. pub(crate) fn find_rsc_push_payload_range(script: &str) -> Option<(usize, usize)> { let cap = RSC_PUSH_CALL_PATTERN.captures(script)?; + let call = cap.get(0)?; + // The receiver must stand alone: `myAnalytics.__next_f` and `foo.window.__next_f` + // are unrelated member expressions, not Next.js Flight receivers. + if call.start() > 0 && is_receiver_continuation(script.as_bytes()[call.start() - 1]) { + return None; + } + payload_range_after_call(script, &cap) +} + +/// Find the payload string boundaries of a claim whose receiver already streamed. +/// +/// Callers must first prove the receiver with [`receiver_context_is_flight_push`]; +/// `script` has to begin at the `__next_f` identifier. +pub(crate) fn find_trimmed_rsc_push_payload_range(script: &str) -> Option<(usize, usize)> { + let cap = RSC_PUSH_CALL_PATTERN_TRIMMED.captures(script)?; + payload_range_after_call(script, &cap) +} + +fn payload_range_after_call(script: &str, cap: ®ex::Captures<'_>) -> Option<(usize, usize)> { let quote_match = cap.get(1)?; let quote = quote_match .as_str() @@ -375,4 +439,50 @@ mod tests { None ); } + + #[test] + fn find_rsc_push_payload_range_accepts_qualified_receivers() { + for script in [ + r#"self.__next_f.push([1,"payload"])"#, + r#"window.__next_f.push([1,"payload"])"#, + r#";(self.__next_f=self.__next_f||[]).push([1,"payload"])"#, + ] { + let (start, end) = find_rsc_push_payload_range(script) + .unwrap_or_else(|| panic!("should match qualified receiver in `{script}`")); + assert_eq!( + &script[start..end], + "payload", + "should capture the Flight payload of `{script}`" + ); + } + } + + #[test] + fn find_rsc_push_payload_range_requires_a_qualified_receiver() { + assert_eq!( + find_rsc_push_payload_range(r#"__next_f.push([1,"payload"])"#), + None, + "should not claim an unqualified push whose receiver cannot be verified" + ); + } + + #[test] + fn find_rsc_push_payload_range_rejects_foreign_receivers() { + for script in [ + r#"myAnalytics.__next_f.push([1,"https://origin.example.com/track"])"#, + r#"foo.bar.__next_f.push([1,"payload"])"#, + r#"window.myapp.__next_f.push([1,"payload"])"#, + r#"a__next_f.push([1,"payload"])"#, + r#"var x=1; other.__next_f.push([1,"payload"])"#, + r#"foo.window.__next_f.push([1,"payload"])"#, + r#"myself.__next_f.push([1,"payload"])"#, + r#"(myself.__next_f=self.__next_f||[]).push([1,"payload"])"#, + ] { + assert_eq!( + find_rsc_push_payload_range(script), + None, + "should not treat `{script}` as a Next.js Flight push" + ); + } + } } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index d858e5a12..95162688f 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -17,6 +17,7 @@ use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; use crate::platform::RuntimeServices; use crate::settings::Settings; +use crate::streaming_processor::StreamProcessor; /// Action returned by attribute rewriters to describe how the runtime should mutate the element. #[derive(Debug, Clone, PartialEq, Eq)] @@ -97,6 +98,7 @@ pub struct IntegrationScriptContext<'a> { pub request_scheme: &'a str, pub origin_host: &'a str, pub is_last_in_text_node: bool, + pub max_buffered_script_bytes: usize, pub document_state: &'a IntegrationDocumentState, } @@ -549,26 +551,22 @@ pub struct IntegrationHtmlContext<'a> { pub document_state: &'a IntegrationDocumentState, } -/// Trait for integration-provided HTML post-processors. -/// These run after streaming HTML processing to handle cases that require -/// access to the complete HTML (e.g., cross-script RSC T-chunks). -pub trait IntegrationHtmlPostProcessor: Send + Sync { - /// Identifier for logging/diagnostics. - fn integration_id(&self) -> &'static str; +/// Owned request data supplied when an integration creates an HTML stream processor. +#[derive(Clone)] +pub struct IntegrationHtmlStreamContext { + pub request_host: String, + pub request_scheme: String, + pub origin_host: String, + pub document_state: IntegrationDocumentState, +} - /// Fast preflight check to decide whether post-processing should run for this document. - /// - /// Implementations should keep this cheap (e.g., a substring check) because it may run on - /// every HTML response when the integration is enabled. - fn should_process(&self, html: &str, ctx: &IntegrationHtmlContext<'_>) -> bool { - let _ = (html, ctx); - false - } +/// Creates one mutable HTML output processor for each document. +pub trait IntegrationHtmlStreamProcessorFactory: Send + Sync { + /// Identifier for logging and diagnostics. + fn integration_id(&self) -> &'static str; - /// Post-process complete HTML content. - /// This is called after streaming HTML processing with the complete HTML. - /// Implementations should mutate `html` in-place and return `true` when changes were made. - fn post_process(&self, html: &mut String, ctx: &IntegrationHtmlContext<'_>) -> bool; + /// Create a request-local streaming processor. + fn create(&self, context: IntegrationHtmlStreamContext) -> Box; } /// Trait for integration-provided HTML head injections. @@ -592,7 +590,7 @@ pub struct IntegrationRegistration { pub proxies: Vec>, pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, - pub html_post_processors: Vec>, + pub html_stream_processors: Vec>, pub head_injectors: Vec>, pub request_filters: Vec>, } @@ -618,7 +616,7 @@ impl IntegrationRegistrationBuilder { proxies: Vec::new(), attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), - html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), }, @@ -647,11 +645,11 @@ impl IntegrationRegistrationBuilder { } #[must_use] - pub fn with_html_post_processor( + pub fn with_html_stream_processor( mut self, - processor: Arc, + processor: Arc, ) -> Self { - self.registration.html_post_processors.push(processor); + self.registration.html_stream_processors.push(processor); self } @@ -708,7 +706,7 @@ struct IntegrationRegistryInner { disabled_js_ids: Vec<&'static str>, html_rewriters: Vec>, script_rewriters: Vec>, - html_post_processors: Vec>, + html_stream_processors: Vec>, head_injectors: Vec>, request_filters: Vec>, } @@ -729,7 +727,7 @@ impl Default for IntegrationRegistryInner { disabled_js_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), - html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), } @@ -886,8 +884,8 @@ impl IntegrationRegistry { .extend(registration.attribute_rewriters); inner.script_rewriters.extend(registration.script_rewriters); inner - .html_post_processors - .extend(registration.html_post_processors); + .html_stream_processors + .extend(registration.html_stream_processors); inner.head_injectors.extend(registration.head_injectors); inner.request_filters.extend(registration.request_filters); if registration.js_disabled { @@ -1069,19 +1067,12 @@ impl IntegrationRegistry { self.inner.script_rewriters.clone() } - /// Check whether any HTML post-processors are registered. - /// - /// Cheaper than [`html_post_processors()`](Self::html_post_processors) when - /// only the presence check is needed — avoids cloning `Vec>`. - #[must_use] - pub fn has_html_post_processors(&self) -> bool { - !self.inner.html_post_processors.is_empty() - } - - /// Expose registered HTML post-processors. + /// Expose registered per-document HTML stream processor factories. #[must_use] - pub fn html_post_processors(&self) -> Vec> { - self.inner.html_post_processors.clone() + pub fn html_stream_processor_factories( + &self, + ) -> Vec> { + self.inner.html_stream_processors.clone() } /// Collect HTML snippets for insertion at the start of ``. @@ -1254,7 +1245,7 @@ impl IntegrationRegistry { enabled_integration_ids: Vec::new(), html_rewriters: attribute_rewriters, script_rewriters, - html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), deferred_js_ids: Vec::new(), @@ -1284,7 +1275,7 @@ impl IntegrationRegistry { enabled_integration_ids: Vec::new(), html_rewriters: attribute_rewriters, script_rewriters, - html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors, request_filters: Vec::new(), deferred_js_ids: Vec::new(), @@ -1310,7 +1301,7 @@ impl IntegrationRegistry { enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), - html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters, deferred_js_ids: Vec::new(), @@ -1376,7 +1367,7 @@ impl IntegrationRegistry { enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), - html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), deferred_js_ids: Vec::new(), @@ -1512,18 +1503,6 @@ mod tests { } } - struct NoopHtmlPostProcessor; - - impl IntegrationHtmlPostProcessor for NoopHtmlPostProcessor { - fn integration_id(&self) -> &'static str { - "noop" - } - - fn post_process(&self, _html: &mut String, _ctx: &IntegrationHtmlContext<'_>) -> bool { - false - } - } - struct EchoProxy; #[async_trait(?Send)] @@ -1582,20 +1561,73 @@ mod tests { ); } + struct CountingStreamFactory(&'static str); + + impl IntegrationHtmlStreamProcessorFactory for CountingStreamFactory { + fn integration_id(&self) -> &'static str { + self.0 + } + + fn create(&self, _context: IntegrationHtmlStreamContext) -> Box { + struct CountingStreamProcessor(usize); + + impl StreamProcessor for CountingStreamProcessor { + fn process_chunk( + &mut self, + chunk: &[u8], + _is_last: bool, + ) -> std::io::Result> { + self.0 += 1; + let mut output = self.0.to_string().into_bytes(); + output.extend_from_slice(chunk); + Ok(output) + } + } + + Box::new(CountingStreamProcessor(0)) + } + } + #[test] - fn default_html_post_processor_should_process_is_false() { - let processor = NoopHtmlPostProcessor; - let document_state = IntegrationDocumentState::default(); - let ctx = IntegrationHtmlContext { - request_host: "proxy.example.com", - request_scheme: "https", - origin_host: "origin.example.com", - document_state: &document_state, + fn html_stream_factories_preserve_order_and_create_isolated_sessions() { + let registration = IntegrationRegistration::builder("test") + .with_html_stream_processor(Arc::new(CountingStreamFactory("first"))) + .with_html_stream_processor(Arc::new(CountingStreamFactory("second"))) + .build(); + let identifiers: Vec<_> = registration + .html_stream_processors + .iter() + .map(|factory| factory.integration_id()) + .collect(); + assert_eq!( + identifiers, + ["first", "second"], + "should preserve factory registration order", + ); + + let context = IntegrationHtmlStreamContext { + request_host: "proxy.example.com".to_owned(), + request_scheme: "https".to_owned(), + origin_host: "origin.example.com".to_owned(), + document_state: IntegrationDocumentState::default(), }; + let factory = ®istration.html_stream_processors[0]; + let mut first = factory.create(context.clone()); + let mut second = factory.create(context); - assert!( - !processor.should_process("", &ctx), - "Default `should_process` should be false to avoid running post-processing unexpectedly" + assert_eq!( + first + .process_chunk(b"a", false) + .expect("should process first session"), + b"1a", + "should initialize the first session counter", + ); + assert_eq!( + second + .process_chunk(b"b", true) + .expect("should process second session"), + b"1b", + "should initialize an independent second session counter", ); } diff --git a/crates/trusted-server-core/src/migration_guards.rs b/crates/trusted-server-core/src/migration_guards.rs index ad3e5350c..411bb4d29 100644 --- a/crates/trusted-server-core/src/migration_guards.rs +++ b/crates/trusted-server-core/src/migration_guards.rs @@ -124,10 +124,6 @@ fn checked_sources() -> &'static [(&'static str, &'static str)] { include_str!("integrations/lockr.rs"), ), ("integrations/mod.rs", include_str!("integrations/mod.rs")), - ( - "integrations/nextjs/html_post_process.rs", - include_str!("integrations/nextjs/html_post_process.rs"), - ), ( "integrations/nextjs/mod.rs", include_str!("integrations/nextjs/mod.rs"), @@ -140,6 +136,10 @@ fn checked_sources() -> &'static [(&'static str, &'static str)] { "integrations/nextjs/rsc_placeholders.rs", include_str!("integrations/nextjs/rsc_placeholders.rs"), ), + ( + "integrations/nextjs/rsc_stream.rs", + include_str!("integrations/nextjs/rsc_stream.rs"), + ), ( "integrations/nextjs/script_rewriter.rs", include_str!("integrations/nextjs/script_rewriter.rs"), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 05daf0b4e..a7e02397f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -638,14 +638,26 @@ struct PublisherBodyProcessor { } impl PublisherBodyProcessor { + /// Build the body processor, returning any deferred inline seam token it + /// installed alongside it. + /// + /// The token is returned rather than stored so a caller that has no seam + /// controller has to discard it visibly. Dropping it silently would ship the + /// raw marker comment to the browser and inject no bids. fn new( params: &OwnedProcessResponseParams, settings: &Settings, integration_registry: &IntegrationRegistry, - ) -> Result> { + ) -> Result<(Self, Option>), Report> { let is_html = is_html_content_type(¶ms.content_type); let is_rsc_flight = content_type_contains_ascii_case_insensitive(¶ms.content_type, "text/x-component"); + let inline_seam_token = deferred_inline_seam_token( + settings, + params.template_cache_key.is_some(), + params.ad_slots_script.is_some(), + is_html && params.dispatched_auction.is_some(), + ); let inner: Box = if is_html { Box::new(create_html_stream_processor(HtmlStreamProcessorParams { origin_host: ¶ms.origin_host, @@ -659,6 +671,9 @@ impl PublisherBodyProcessor { gpt_diagnostics: params.gpt_diagnostics.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), + deferred_inline_marker: inline_seam_token + .as_ref() + .map(|token| String::from_utf8_lossy(token).into_owned()), })?) } else if is_rsc_flight { Box::new(RscFlightUrlRewriter::new( @@ -676,7 +691,7 @@ impl PublisherBodyProcessor { )) }; - Ok(Self { inner }) + Ok((Self { inner }, inline_seam_token)) } } @@ -740,6 +755,7 @@ fn process_response_streaming( gpt_diagnostics: params.gpt_diagnostics.cloned(), shared_template_authorized: params.shared_template_authorized, csp_nonce_observed: params.csp_nonce_observed.cloned(), + deferred_inline_marker: None, })?; StreamingPipeline::new(config, processor) .with_max_pending_decoded_bytes(max_pending_decoded_bytes) @@ -792,7 +808,19 @@ async fn process_response_streaming_async( } else { input_compression }; - let mut processor = PublisherBodyProcessor::new(params, settings, integration_registry)?; + // This path has no seam controller, so it must never be reached with a + // pending auction; `deferred_inline_seam_token` returns `None` for it. + let (mut processor, inline_seam_token) = + PublisherBodyProcessor::new(params, settings, integration_registry)?; + if inline_seam_token.is_some() { + // A `debug_assert!` would be compiled out of the release wasm builds that + // actually ship, which is exactly where an unresolved token would reach a + // browser as a raw marker comment with no bids injected. + log::error!( + "publisher body-close seam token minted on a path with no seam controller; dropping it" + ); + } + drop(inline_seam_token); process_body_chunks_async( body, output, @@ -967,15 +995,19 @@ impl Drop for DispatchedAuctionGuard { /// Mutable auction-hold state threaded through the streaming hold pipeline. struct AuctionHoldState { - hold: Option, + hold: Option, dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry, } impl AuctionHoldState { - fn new(dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry) -> Self { + fn new( + dispatched: DispatchedAuctionGuard, + telemetry: AuctionTelemetryCarry, + seam_token: Option>, + ) -> Self { Self { - hold: Some(BodyCloseHoldBuffer::new()), + hold: seam_token.map(InlineBodyCloseSeam::new), dispatched, telemetry, } @@ -1004,13 +1036,13 @@ async fn abandon_hold_auction( } } -/// Output of a single close-body hold step, split at the auction-collection +/// Output of one parser-confirmed seam step, split at the auction-collection /// barrier. /// /// `ready` is the prefix the caller must emit *before* collecting the auction, /// so a small page whose `` lands in the first source chunk still /// streams its document prefix immediately instead of stalling behind the -/// auction. `close_found` signals that `( collect_refs: &AuctionCollectDeps<'_>, ) -> Result> { let mut ready = Vec::new(); + let processed = + match processor + .process_chunk(chunk, false) + .change_context(TrustedServerError::Proxy { + message: "Failed to process chunk".to_string(), + }) { + Ok(processed) => processed, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + }; let bytes: Cow<'_, [u8]> = match state.hold.as_mut() { - // Once the hold has been released the chunk streams straight through, - // borrowed rather than copied. - None => Cow::Borrowed(chunk), - Some(hold_buffer) => Cow::Owned(hold_buffer.push(chunk)), + None => Cow::Borrowed(&processed), + Some(seam) => Cow::Owned(seam.push(&processed)), }; - match process_and_encode_chunk(processor, encoder, &bytes, false, "Failed to process chunk") { - Ok(Some(encoded)) => ready.push(encoded), - Ok(None) => {} + match encoder.encode_chunk(bytes.into_owned()) { + Ok(encoded) if !encoded.is_empty() => ready.push(bytes::Bytes::from(encoded)), + Ok(_) => {} Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; - return Err(err); + abandon_hold_auction(state, collect_refs.services, "stream_encode_error").await; + return Err(err.change_context(TrustedServerError::Proxy { + message: "Failed to encode processed chunk".to_string(), + })); } } - let close_found = state - .hold - .as_ref() - .is_some_and(BodyCloseHoldBuffer::found_close); + let close_found = state.hold.as_ref().is_some_and(InlineBodyCloseSeam::found); Ok(HoldStepSegments { ready, close_found }) } -/// Collect the dispatched auction and process the held `` tail. +/// Collect the dispatched auction and emit bids before the parsed closing tail. /// /// Call only after [`hold_step_decoded_chunk`] (or /// [`hold_finish_ready_segments`]) reports `close_found` and the ready prefix /// has already been emitted: /// collecting here — after the prefix streams — is what keeps the auction -/// riding alongside transfer instead of blocking it. Collection runs before the -/// tail is processed so `lol_html` sees live bids at the injection point. -async fn hold_collect_close_tail( - processor: &mut P, +/// riding alongside transfer instead of blocking it. The parser has already +/// transformed the tail; replace its marker with the collected bids before encoding. +async fn hold_collect_close_tail( encoder: &mut BodyStreamEncoder, state: &mut AuctionHoldState, collect_refs: &AuctionCollectDeps<'_>, @@ -1083,24 +1123,25 @@ async fn hold_collect_close_tail( // collect await above was still pending is reported. state.dispatched.disarm(); + let bids = inline_bids_script(collect_refs.ad_bids_state); + let encoded = encoder.encode_chunk(bids.into_bytes())?; + if !encoded.is_empty() { + segments.push(bytes::Bytes::from(encoded)); + } + let held = state .hold .take() - .expect("should have close-body hold buffer") + .expect("should have inline body seam") .finish(); - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &held, - false, - "Failed to process held body close", - )? { - segments.push(encoded); + let encoded = encoder.encode_chunk(held)?; + if !encoded.is_empty() { + segments.push(bytes::Bytes::from(encoded)); } Ok(segments) } -/// Pull and decode the next chunk of the close-body hold pipeline, feeding it +/// Pull, decode, process, and scan the next chunk of the inline seam pipeline. /// through [`hold_step_decoded_chunk`]. /// /// Returns `Ok(None)` when the source is exhausted; the caller must then emit @@ -1160,7 +1201,7 @@ async fn hold_finish_ready_segments( encoder: &mut BodyStreamEncoder, state: &mut AuctionHoldState, collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { +) -> Result> { let decoded_tail = match decoder.finish() { Ok(decoded_tail) => decoded_tail, Err(err) => { @@ -1168,42 +1209,77 @@ async fn hold_finish_ready_segments( return Err(err); } }; - if decoded_tail.is_empty() { - return Ok(Vec::new()); - } - let step = + let mut step = hold_step_decoded_chunk(processor, encoder, &decoded_tail, state, collect_refs).await?; - Ok(step.ready) + + let final_processed = + match processor + .process_chunk(&[], true) + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize processor".to_string(), + }) { + Ok(processed) => processed, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + }; + let final_ready = match state.hold.as_mut() { + Some(seam) => seam.push(&final_processed), + None => final_processed, + }; + let encoded = match encoder.encode_chunk(final_ready) { + Ok(encoded) => encoded, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_encode_error").await; + return Err(err.change_context(TrustedServerError::Proxy { + message: "Failed to encode finalized HTML".to_string(), + })); + } + }; + if !encoded.is_empty() { + step.ready.push(bytes::Bytes::from(encoded)); + } + step.close_found = state.hold.as_ref().is_some_and(InlineBodyCloseSeam::found); + + if !step.close_found + && let Some(seam) = state.hold.take() + { + let encoded = match encoder.encode_chunk(seam.finish()) { + Ok(encoded) => encoded, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_encode_error").await; + return Err(err); + } + }; + if !encoded.is_empty() { + step.ready.push(bytes::Bytes::from(encoded)); + } + } + Ok(step) } -/// Finalize the close-body hold pipeline after [`hold_finish_ready_segments`]. +/// Finalize the inline seam pipeline after [`hold_finish_ready_segments`]. /// -/// Collects the auction if the close-body tag never streamed, processes the held -/// tail plus the processor's final chunk, and emits the encoder trailer. Returns +/// Collects the auction if the body-end marker never streamed, releases any +/// remaining seam bytes, and emits the encoder trailer. Returns /// the encoded segments for the caller to emit. -async fn hold_finish_tail_segments( - processor: &mut P, +async fn hold_finish_tail_segments( encoder: &mut BodyStreamEncoder, state: &mut AuctionHoldState, collect_refs: &AuctionCollectDeps<'_>, ) -> Result, Report> { let mut segments = Vec::new(); - // If the hold is still armed the auction was never collected mid-stream: - // `` arrived only in the decoder tail, or the document had none at - // all. Collect now and flush the held remainder before finalizing. - if state.hold.is_some() { - segments.extend(hold_collect_close_tail(processor, encoder, state, collect_refs).await?); + if let Some(dispatched) = state.dispatched.take() { + collect_stream_auction(dispatched, state.telemetry.take(), collect_refs).await; + state.dispatched.disarm(); } - - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &[], - true, - "Failed to finalize processor", - )? { - segments.push(encoded); + if let Some(seam) = state.hold.take() { + let encoded = encoder.encode_chunk(seam.finish())?; + if !encoded.is_empty() { + segments.push(bytes::Bytes::from(encoded)); + } } let trailer = encoder.finish()?; if !trailer.is_empty() { @@ -1241,6 +1317,8 @@ struct HtmlStreamProcessorParams<'a> { shared_template_authorized: bool, /// Where the transform records a response-bound CSP nonce, when one matters. csp_nonce_observed: Option>, + /// Request-specific parser marker used by a pending inline auction. + deferred_inline_marker: Option, } /// The diagnostics decision the template may carry. @@ -1384,6 +1462,27 @@ pub(crate) fn body_close_injection( } } +fn deferred_inline_seam_token( + settings: &Settings, + shared_template_authorized: bool, + head_script_present: bool, + auction_pending: bool, +) -> Option> { + (auction_pending + && head_script_present + && matches!( + effective_assembly_mode(settings, shared_template_authorized), + AssemblyMode::Inline + )) + .then(|| { + format!( + "", + uuid::Uuid::new_v4().simple() + ) + .into_bytes() + }) +} + fn create_html_stream_processor( params: HtmlStreamProcessorParams<'_>, ) -> Result, Report> { @@ -1398,7 +1497,12 @@ fn create_html_stream_processor( ); let assembly_mode = effective_assembly_mode(params.settings, params.shared_template_authorized); - let body_close = body_close_injection(assembly_mode, params.ad_slots_script.is_some()); + let body_close = match (assembly_mode, params.deferred_inline_marker) { + (AssemblyMode::Inline, Some(marker)) if params.ad_slots_script.is_some() => { + BodyCloseInjection::DeferredInlineMarker(marker) + } + _ => body_close_injection(assembly_mode, params.ad_slots_script.is_some()), + }; let gpt_diagnostics = template_gpt_diagnostics(assembly_mode, params.gpt_diagnostics); @@ -2448,9 +2552,9 @@ pub async fn publisher_response_into_streaming_response( response.headers_mut().remove(header::CONTENT_LENGTH); let mut params = *params; - let mut processor = + let (mut processor, inline_seam_token) = match PublisherBodyProcessor::new(¶ms, &settings, integration_registry) { - Ok(processor) => processor, + Ok(built) => built, Err(err) => { // Parity with the buffered finalizer: a processor // construction failure abandons the dispatched auction @@ -2485,7 +2589,7 @@ pub async fn publisher_response_into_streaming_response( let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE) .with_max_bytes(max_body_bytes); - // HTML rides the close-body hold so bids land before ``; + // HTML rides the parser-confirmed seam so bids land before ``; // non-HTML has no injection point, so its auction is collected // before any byte streams (matching the buffered finalizer). let mut hold_auction = None; @@ -2510,7 +2614,11 @@ pub async fn publisher_response_into_streaming_response( } if let Some((guard, telemetry)) = hold_auction { - let mut state = AuctionHoldState::new(guard, telemetry); + let mut state = AuctionHoldState::new( + guard, + telemetry, + inline_seam_token, + ); let collect_refs = AuctionCollectDeps { price_granularity: params.price_granularity, ad_bids_state: ¶ms.ad_bids_state, @@ -2542,7 +2650,6 @@ pub async fn publisher_response_into_streaming_response( } if step.close_found { for encoded in hold_collect_close_tail( - &mut processor, &mut encoder, &mut state, &collect_refs, @@ -2559,7 +2666,7 @@ pub async fn publisher_response_into_streaming_response( // before collection, for the same reason as the mid-stream // prefix above: a small compressed page can surface its // whole document here. - for encoded in hold_finish_ready_segments( + let final_step = hold_finish_ready_segments( &mut processor, &mut decoder, &mut encoder, @@ -2567,12 +2674,23 @@ pub async fn publisher_response_into_streaming_response( &collect_refs, ) .await - .map_err(publisher_stream_error)? - { + .map_err(publisher_stream_error)?; + for encoded in final_step.ready { yield encoded; } + if final_step.close_found { + for encoded in hold_collect_close_tail( + &mut encoder, + &mut state, + &collect_refs, + ) + .await + .map_err(publisher_stream_error)? + { + yield encoded; + } + } for encoded in hold_finish_tail_segments( - &mut processor, &mut encoder, &mut state, &collect_refs, @@ -2794,17 +2912,16 @@ pub fn stream_publisher_body( process_response_streaming(body, output, &borrowed, output_compression) } -/// Stream publisher body with a `` handler with bids now in state. +/// 3. The generated marker is replaced with the collected bid script. /// /// For non-HTML content types the auction is collected before any body bytes /// are written (no `` to inject). If `params.dispatched_auction` is @@ -2871,9 +2988,13 @@ pub async fn stream_publisher_body_async( 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 ). + // HTML: let lol_html mark the structural body end for the auction seam. + let inline_seam_token = deferred_inline_seam_token( + settings, + params.template_cache_key.is_some(), + params.ad_slots_script.is_some(), + true, + ); let mut processor = match create_html_stream_processor(HtmlStreamProcessorParams { origin_host: ¶ms.origin_host, request_host: ¶ms.request_host, @@ -2886,6 +3007,9 @@ pub async fn stream_publisher_body_async( gpt_diagnostics: params.gpt_diagnostics.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), + deferred_inline_marker: inline_seam_token + .as_ref() + .map(|token| String::from_utf8_lossy(token).into_owned()), }) { Ok(processor) => processor, Err(err) => { @@ -2910,6 +3034,7 @@ pub async fn stream_publisher_body_async( body, output, &mut processor, + inline_seam_token, input_compression, output_compression, AuctionCollectCtx { @@ -3541,12 +3666,13 @@ struct AuctionCollectDeps<'a> { request_origin: String, } -/// Run the close-body hold loop for HTML bodies, collecting the auction before -/// the raw `( body: EdgeBody, output: &mut W, processor: &mut P, + inline_seam_token: Option>, input_compression: Compression, output_compression: Compression, ctx: AuctionCollectCtx<'_>, @@ -3561,6 +3687,7 @@ async fn stream_html_with_auction_hold( output_compression, ctx, max_body_bytes, + inline_seam_token, ) .await; } @@ -3571,25 +3698,29 @@ async fn stream_html_with_auction_hold( let body = body_as_reader(body)?; if output_compression == Compression::None { return match input_compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, + Compression::None => { + body_close_hold_loop(body, output, processor, ctx, inline_seam_token).await + } Compression::Gzip => { let decoder = GzipDecodeReader::new(body, max_body_bytes); - body_close_hold_loop(decoder, output, processor, ctx).await + body_close_hold_loop(decoder, output, processor, ctx, inline_seam_token).await } Compression::Deflate => { let decoder = ZlibDecoder::new(body); - body_close_hold_loop(decoder, output, processor, ctx).await + body_close_hold_loop(decoder, output, processor, ctx, inline_seam_token).await } Compression::Brotli => { let decoder = Decompressor::new(body, STREAM_CHUNK_SIZE); - body_close_hold_loop(decoder, output, processor, ctx).await + body_close_hold_loop(decoder, output, processor, ctx, inline_seam_token).await } }; } debug_assert_eq!(input_compression, output_compression); match input_compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, + Compression::None => { + body_close_hold_loop(body, output, processor, ctx, inline_seam_token).await + } Compression::Gzip => { // `GzipDecodeReader` decodes concatenated gzip members (RFC 1952) // and bounds decoded output, unlike `flate2::read::GzDecoder`, which @@ -3597,7 +3728,7 @@ async fn stream_html_with_auction_hold( // markup (potentially including ``) on buffered adapters. let decoder = GzipDecodeReader::new(body, max_body_bytes); 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, ctx, inline_seam_token).await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip encoder".to_string(), })?; @@ -3606,7 +3737,7 @@ 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, ctx, inline_seam_token).await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize deflate encoder".to_string(), })?; @@ -3621,7 +3752,7 @@ 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, ctx, inline_seam_token).await?; let _ = encoder.into_inner(); Ok(()) } @@ -3639,6 +3770,10 @@ async fn stream_html_with_auction_hold( /// Cloudflare, Spin) never produce `Body::Stream` because the publisher fetch /// is gated on `supports_streaming_responses()`. It is groundwork for those /// adapters' streaming cutover; Fastly uses the lazy stream instead. +#[allow( + clippy::too_many_arguments, + reason = "stream state remains explicit across the shared adapter driver" +)] async fn body_close_hold_loop_stream( body: EdgeBody, writer: &mut W, @@ -3647,6 +3782,7 @@ async fn body_close_hold_loop_stream( output_compression: Compression, ctx: AuctionCollectCtx<'_>, max_body_bytes: usize, + inline_seam_token: Option>, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, @@ -3656,84 +3792,107 @@ async fn body_close_hold_loop_stream( let mut decoder = BodyStreamDecoder::new(input_compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(output_compression); let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_body_bytes); - let mut state = AuctionHoldState::new(DispatchedAuctionGuard::new(dispatched), telemetry); + let mut state = AuctionHoldState::new( + DispatchedAuctionGuard::new(dispatched), + telemetry, + inline_seam_token, + ); - while let Some(step) = hold_step_next_chunk( - &mut source, - &mut decoder, - &mut encoder, - processor, - &mut state, - &collect_refs, - ) - .await? - { - // Write the ready prefix before collecting the auction, matching the - // lazy Fastly stream: only the held `` tail waits on collection. - for encoded in step.ready { + let result = async { + while let Some(step) = hold_step_next_chunk( + &mut source, + &mut decoder, + &mut encoder, + processor, + &mut state, + &collect_refs, + ) + .await? + { + // Write the ready prefix before collecting the auction, matching the + // lazy Fastly stream: only the held `` tail waits on collection. + for encoded in step.ready { + write_encoded_segment(writer, &encoded)?; + } + if step.close_found { + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; + for encoded in + hold_collect_close_tail(&mut encoder, &mut state, &collect_refs).await? + { + write_encoded_segment(writer, &encoded)?; + } + } + } + + // Write the decoder-finalized prefix before collection, matching the lazy + // Fastly stream: only the held `` tail waits on the auction. + let final_step = hold_finish_ready_segments( + processor, + &mut decoder, + &mut encoder, + &mut state, + &collect_refs, + ) + .await?; + for encoded in final_step.ready { write_encoded_segment(writer, &encoded)?; } - if step.close_found { - for encoded in - hold_collect_close_tail(processor, &mut encoder, &mut state, &collect_refs).await? - { + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; + if final_step.close_found { + for encoded in hold_collect_close_tail(&mut encoder, &mut state, &collect_refs).await? { write_encoded_segment(writer, &encoded)?; } } + for encoded in hold_finish_tail_segments(&mut encoder, &mut state, &collect_refs).await? { + write_encoded_segment(writer, &encoded)?; + } + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output".to_string(), + })?; + Ok(()) } - - // Write the decoder-finalized prefix before collection, matching the lazy - // Fastly stream: only the held `` tail waits on the auction. - for encoded in hold_finish_ready_segments( - processor, - &mut decoder, - &mut encoder, - &mut state, - &collect_refs, - ) - .await? - { - write_encoded_segment(writer, &encoded)?; - } - for encoded in - hold_finish_tail_segments(processor, &mut encoder, &mut state, &collect_refs).await? - { - write_encoded_segment(writer, &encoded)?; + .await; + if result.is_err() { + abandon_hold_auction(&mut state, collect_refs.services, "stream_write_error").await; } - writer.flush().change_context(TrustedServerError::Proxy { - message: "Failed to flush output".to_string(), - })?; - Ok(()) + result } -const BODY_CLOSE_PREFIX: &[u8] = b", buffered: Vec, - found_close: bool, + found: bool, } -impl BodyCloseHoldBuffer { - fn new() -> Self { +impl InlineBodyCloseSeam { + fn new(token: Vec) -> Self { + debug_assert!(!token.is_empty()); Self { + token, buffered: Vec::new(), - found_close: false, + found: false, } } fn push(&mut self, chunk: &[u8]) -> Vec { self.buffered.extend_from_slice(chunk); - if self.found_close { + if self.found { 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(); + if let Some(pos) = find_bytes(&self.buffered, &self.token) { + self.found = true; + let ready = self.buffered.drain(..pos).collect(); + self.buffered.drain(..self.token.len()); + return ready; } - let keep_len = BODY_CLOSE_PREFIX.len().saturating_sub(1); + let keep_len = longest_suffix_prefix(&self.buffered, &self.token); if self.buffered.len() <= keep_len { return Vec::new(); } @@ -3742,8 +3901,8 @@ impl BodyCloseHoldBuffer { self.buffered.drain(..split_at).collect() } - fn found_close(&self) -> bool { - self.found_close + fn found(&self) -> bool { + self.found } fn finish(self) -> Vec { @@ -3751,26 +3910,40 @@ impl BodyCloseHoldBuffer { } } -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)) - }) +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + (!needle.is_empty()) + .then(|| { + haystack + .windows(needle.len()) + .position(|window| window == needle) + }) + .flatten() } -/// Core close-body hold loop. -/// -/// Streams processed output until the first case-insensitive ` usize { + let maximum = bytes.len().min(pattern.len().saturating_sub(1)); + (1..=maximum) + .rev() + .find(|length| bytes.ends_with(&pattern[..*length])) + .unwrap_or(0) +} + +fn inline_bids_script(ad_bids_state: &AdBidsState) -> String { + ad_bids_state + .script_cell() + .lock() + .expect("should lock bid state") + .clone() + .unwrap_or_else(build_empty_bids_script) +} + +/// Core parser-confirmed inline-seam loop for reader-backed bodies. async fn body_close_hold_loop( mut reader: R, writer: &mut W, processor: &mut P, ctx: AuctionCollectCtx<'_>, + inline_seam_token: Option>, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, @@ -3778,94 +3951,180 @@ async fn body_close_hold_loop( deps, } = ctx; let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; - let mut hold = Some(BodyCloseHoldBuffer::new()); + let mut hold = inline_seam_token.map(InlineBodyCloseSeam::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(), &deps).await; - - let held = hold.finish(); - write_processed_chunk( - 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( + let final_out = match processor.process_chunk(&[], true).change_context( TrustedServerError::Proxy { message: "Failed to finalize processor".to_string(), }, - )?; - if !final_out.is_empty() { + ) { + Ok(output) => output, + Err(err) => { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_process_error", + ) + .await; + return Err(err); + } + }; + let ready = match hold.as_mut() { + Some(seam) => seam.push(&final_out), + None => final_out, + }; + if let Err(err) = writer - .write_all(&final_out) + .write_all(&ready) .change_context(TrustedServerError::Proxy { message: "Failed to write finalized output".to_string(), + }) + { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } + + if hold.as_ref().is_some_and(InlineBodyCloseSeam::found) { + if let Err(err) = writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + }) { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } + let dispatched = dispatched + .take() + .expect("should have dispatched auction to collect"); + collect_stream_auction(dispatched, telemetry.take(), &deps).await; + writer + .write_all(inline_bids_script(deps.ad_bids_state).as_bytes()) + .change_context(TrustedServerError::Proxy { + message: "Failed to write inline bids".to_string(), + })?; + writer + .write_all(&hold.take().expect("should have inline body seam").finish()) + .change_context(TrustedServerError::Proxy { + message: "Failed to write held body tail".to_string(), })?; + } else { + if let Some(seam) = hold.take() + && let Err(err) = writer.write_all(&seam.finish()).change_context( + TrustedServerError::Proxy { + message: "Failed to write terminal HTML output".to_string(), + }, + ) + { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } + if let Err(err) = writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + }) { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } + if let Some(pending) = dispatched.take() { + collect_stream_auction(pending, telemetry.take(), &deps).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() { + let processed = match processor.process_chunk(&buffer[..n], false).change_context( + TrustedServerError::Proxy { + message: "Failed to process chunk".to_string(), + }, + ) { + Ok(processed) => processed, + Err(err) => { + if let Some(pending) = dispatched.take() { emit_abandoned_auction( deps.services, telemetry.observation.take(), - dispatched, + pending, "stream_process_error", ) .await; } return Err(err); } + }; + let ready = match hold.as_mut() { + Some(seam) => seam.push(&processed), + None => processed, + }; + if let Err(err) = + writer + .write_all(&ready) + .change_context(TrustedServerError::Proxy { + message: "Failed to write processed chunk".to_string(), + }) + { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + 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(), &deps).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", - )?; + if hold.as_ref().is_some_and(InlineBodyCloseSeam::found) { + if let Err(err) = writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + }) { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); } - } else { - write_processed_chunk( - writer, - processor, - &buffer[..n], - false, - "Failed to process chunk", - "Failed to write chunk", - )?; + let pending = dispatched + .take() + .expect("should have dispatched auction to collect"); + collect_stream_auction(pending, telemetry.take(), &deps).await; + writer + .write_all(inline_bids_script(deps.ad_bids_state).as_bytes()) + .change_context(TrustedServerError::Proxy { + message: "Failed to write inline bids".to_string(), + })?; + writer + .write_all(&hold.take().expect("should have inline body seam").finish()) + .change_context(TrustedServerError::Proxy { + message: "Failed to write held body tail".to_string(), + })?; } } Err(e) => { @@ -3891,6 +4150,17 @@ async fn body_close_hold_loop( Ok(()) } +async fn abandon_reader_auction( + dispatched: &mut Option, + telemetry: &mut AuctionTelemetryCarry, + services: &RuntimeServices, + reason: &'static str, +) { + if let Some(pending) = dispatched.take() { + emit_abandoned_auction(services, telemetry.observation.take(), pending, reason).await; + } +} + async fn emit_abandoned_auction( services: &RuntimeServices, observation: Option, @@ -4030,35 +4300,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. @@ -8109,7 +8350,7 @@ mod tests { 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() { + if find_bytes(chunk, b""; let reader = ChunkedReader::new( &[ - b"painted", + b"painted", b"", b"", ], @@ -15625,22 +15867,221 @@ mod tests { }; let mut output = Vec::new(); - body_close_hold_loop(reader, &mut output, &mut processor, ctx) - .await - .expect("should stream body with auction hold"); + body_close_hold_loop( + reader, + &mut output, + &mut processor, + ctx, + Some(token.to_vec()), + ) + .await + .expect("should stream body with auction hold"); 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 output = std::str::from_utf8(&output).expect("should be utf8"); + let painted = output + .find("painted") + .expect("should preserve body content"); + let bids = output + .find("var b=JSON.parse(") + .expect("should inject collected bids"); + let close = output.find("").expect("should preserve body close"); + let late = output + .find("late()") + .expect("should preserve trailing script"); + assert!( + painted < bids && bids < close && close < late, + "output order: {output}" + ); + } + + #[tokio::test] + async fn parser_seam_write_failure_abandons_auction_once() { + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("injected write failure")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let settings = create_test_settings(); + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = noop_services_with_telemetry_sink(Arc::clone(&sink) as _); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let ad_bids_state = AdBidsState::default(); + let ec_context = EcContext::new_for_test(None, ConsentContext::default()); + let ctx = AuctionCollectCtx { + dispatched: DispatchedAuction::empty_for_test(test_auction_request(), 500), + telemetry: AuctionTelemetryCarry { + observation: Some(AuctionObservationContext::from_parts( + AuctionSource::InitialNavigation, + "proxy.example.com", + "/article", + 1, + None, + &ec_context, + )), + auction_request: None, + }, + deps: AuctionCollectDeps { + price_granularity: PriceGranularity::default(), + ad_bids_state: &ad_bids_state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + request_origin: String::new(), + }, + }; + let mut processor = RecordingProcessor { + read_count: Arc::new(AtomicUsize::new(0)), + body_close_processed_at: Arc::new(AtomicUsize::new(0)), + }; + + let error = body_close_hold_loop( + std::io::Cursor::new(b"ready"), + &mut FailingWriter, + &mut processor, + ctx, + Some(b"".to_vec()), + ) + .await + .expect_err("injected writer failure should surface"); + assert!(format!("{error:?}").contains("Failed to write processed chunk")); + + let batches = sink.batches.lock().expect("should lock telemetry batches"); + let summaries: Vec<_> = batches + .iter() + .flat_map(crate::auction::telemetry::AuctionEventBatch::rows) + .filter(|row| row.event_kind == "summary") + .collect(); + assert_eq!(summaries.len(), 1, "should emit one terminal summary"); + assert_eq!(summaries[0].terminal_status.as_deref(), Some("abandoned")); assert_eq!( - std::str::from_utf8(&output).expect("should be utf8"), - "painted", - "post-body chunks should still stream in order" + summaries[0].terminal_reason.as_deref(), + Some("stream_write_error") ); } + #[tokio::test] + async fn parser_seam_async_sink_failures_abandon_auction_once() { + struct FailingWriter { + fail_flush: bool, + } + + impl Write for FailingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.fail_flush { + Ok(buf.len()) + } else { + Err(io::Error::other("injected write failure")) + } + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::other("injected flush failure")) + } + } + + for fail_flush in [false, true] { + for with_close in [false, true] { + let settings = create_test_settings(); + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = noop_services_with_telemetry_sink(Arc::clone(&sink) as _); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let ad_bids_state = AdBidsState::default(); + let ec_context = EcContext::new_for_test(None, ConsentContext::default()); + let ctx = AuctionCollectCtx { + dispatched: DispatchedAuction::empty_for_test(test_auction_request(), 500), + telemetry: AuctionTelemetryCarry { + observation: Some(AuctionObservationContext::from_parts( + AuctionSource::InitialNavigation, + "proxy.example.com", + "/article", + 1, + None, + &ec_context, + )), + auction_request: None, + }, + deps: AuctionCollectDeps { + price_granularity: PriceGranularity::default(), + ad_bids_state: &ad_bids_state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + request_origin: String::new(), + }, + }; + let mut processor = RecordingProcessor { + read_count: Arc::new(AtomicUsize::new(0)), + body_close_processed_at: Arc::new(AtomicUsize::new(0)), + }; + let html = if with_close { + "ready" + } else { + "ready" + }; + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + html.as_bytes(), + )])); + + let error = body_close_hold_loop_stream( + body, + &mut FailingWriter { fail_flush }, + &mut processor, + Compression::None, + Compression::None, + ctx, + settings.publisher.max_buffered_body_bytes, + Some(b"".to_vec()), + ) + .await + .expect_err("should propagate the sink failure before collecting"); + let expected_error = if fail_flush { + "Failed to flush output before auction collection" + } else { + "Failed to write encoded chunk" + }; + assert!( + format!("{error:?}").contains(expected_error), + "should preserve the sink error: {error:?}" + ); + + let batches = sink.batches.lock().expect("should lock telemetry batches"); + let summaries: Vec<_> = batches + .iter() + .flat_map(crate::auction::telemetry::AuctionEventBatch::rows) + .filter(|row| row.event_kind == "summary") + .collect(); + assert_eq!( + summaries.len(), + 1, + "should emit one terminal summary for fail_flush={fail_flush}, with_close={with_close}" + ); + assert_eq!( + summaries[0].terminal_status.as_deref(), + Some("abandoned"), + "should abandon the uncollected auction" + ); + assert_eq!( + summaries[0].terminal_reason.as_deref(), + Some("stream_write_error"), + "should classify write and flush failures consistently" + ); + } + } + } + #[tokio::test] async fn hold_step_yields_ready_prefix_before_collecting_auction() { // A small page whose `` lands in the first source chunk must @@ -15653,6 +16094,7 @@ mod tests { let services = noop_services(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let ad_bids_state = AdBidsState::default(); + let token = b""; let mut state = AuctionHoldState::new( DispatchedAuctionGuard::new(DispatchedAuction::empty_for_test( test_auction_request(), @@ -15662,6 +16104,7 @@ mod tests { observation: None, auction_request: None, }, + Some(token.to_vec()), ); let collect_refs = AuctionCollectDeps { price_granularity: PriceGranularity::default(), @@ -15682,7 +16125,7 @@ mod tests { let step = hold_step_decoded_chunk( &mut processor, &mut encoder, - b"painted", + b"painted", &mut state, &collect_refs, ) @@ -15691,7 +16134,7 @@ mod tests { assert!( step.close_found, - " in the first chunk must be detected" + "parser marker in the first chunk must be detected" ); let ready: Vec = step.ready.iter().flat_map(|b| b.to_vec()).collect(); assert_eq!( @@ -15708,14 +16151,14 @@ mod tests { "auction must not be collected while the ready prefix is emitted" ); - let tail = hold_collect_close_tail(&mut processor, &mut encoder, &mut state, &collect_refs) + let tail = hold_collect_close_tail(&mut encoder, &mut state, &collect_refs) .await .expect("collect should succeed"); let tail_bytes: Vec = tail.iter().flat_map(|b| b.to_vec()).collect(); - assert_eq!( - std::str::from_utf8(&tail_bytes).expect("held tail should be utf8"), - "", - "the held close tail must be emitted after collection" + let tail = std::str::from_utf8(&tail_bytes).expect("held tail should be utf8"); + assert!( + tail.contains("var b=JSON.parse(") && tail.ends_with(""), + "collected bids and the held close tail must be emitted together: {tail}" ); assert!( ad_bids_state @@ -15728,10 +16171,11 @@ mod tests { } #[test] - fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { - let mut hold = BodyCloseHoldBuffer::new(); + fn inline_body_close_seam_holds_tail_in_single_chunk() { + let token = b""; + let mut hold = InlineBodyCloseSeam::new(token.to_vec()); - let ready = hold.push(b"painted"); + let ready = hold.push(b"painted"); let held = hold.finish(); assert_eq!( @@ -15747,11 +16191,12 @@ mod tests { } #[test] - fn body_close_hold_buffer_holds_close_body_tail_across_chunks() { - let mut hold = BodyCloseHoldBuffer::new(); + fn inline_body_close_seam_holds_tail_across_chunks() { + let token = b""; + let mut hold = InlineBodyCloseSeam::new(token.to_vec()); - let first = hold.push(b"painted"); + let first = hold.push(b"painted"); let held = hold.finish(); let streamed = [first, second].concat(); @@ -15767,6 +16212,29 @@ mod tests { ); } + #[test] + fn inline_body_close_seam_matches_every_token_split_and_ignores_other_tokens() { + let token = b""; + for split in 0..=token.len() { + let mut seam = InlineBodyCloseSeam::new(token.to_vec()); + let mut ready = seam.push(b""); + ready.extend(seam.push(&token[..split])); + ready.extend(seam.push(&token[split..])); + assert!(seam.found(), "should match token split at {split}"); + assert_eq!( + ready, b"", + "should release all bytes before split {split}" + ); + assert!(seam.finish().is_empty()); + } + + let other = b""; + let mut seam = InlineBodyCloseSeam::new(token.to_vec()); + let ready = seam.push(other); + assert!(!seam.found()); + assert_eq!([ready, seam.finish()].concat(), other); + } + #[test] fn unsupported_encoding_response_is_returned_unmodified() { assert_eq!( @@ -17809,11 +18277,20 @@ mod tests { #[test] fn streaming_finalize_auction_hold_emits_prefix_before_origin_eof() { - // The auction-hold path must stream the document prefix (up to the held - // `` tail) before the origin finishes and before the auction is - // collected — otherwise the hold reintroduces the FCP regression. The - // origin sends the head/body prefix (no ``) then stays Pending. - let page = b"

hello

more streamed content here

"; + // A body-close literal in script data must not stop streaming. Only the + // request token emitted by lol_html at the structural end is a seam. + let page = br#"
still streaming
"#; + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + }), + ) + .expect("should enable Next.js"); let params = html_stream_params( "", Some(DispatchedAuction::empty_for_test( @@ -17821,16 +18298,21 @@ mod tests { 10, )), ); - let body = streaming_finalize_response( + let body = streaming_finalize_response_with_settings( params, origin_chunk_then_pending(bytes::Bytes::from(&page[..])), + settings, ); let first = first_lazy_body_chunk(body); let html = String::from_utf8(first.to_vec()).expect("should be valid UTF-8"); assert!( - html.contains("hello"), - "auction-hold path must stream the prefix before EOF. Got: {html}" + html.contains("") && html.contains("still streaming"), + "RSC script data and later article bytes must stream before EOF. Got: {html}" + ); + assert!( + html.contains("proxy.example.com/app") && !html.contains("origin.example.com/app"), + "Next.js rewriting must complete before the parser seam: {html}" ); assert!( html.contains(".adSlots=JSON.parse"), @@ -17884,6 +18366,270 @@ mod tests { ); } + struct GatedAuctionHttpClient { + inner: StubHttpClient, + released: std::sync::atomic::AtomicBool, + collections: AtomicUsize, + } + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformHttpClient for GatedAuctionHttpClient { + async fn send( + &self, + request: crate::platform::PlatformHttpRequest, + ) -> Result> + { + self.inner.send(request).await + } + + async fn send_async( + &self, + request: crate::platform::PlatformHttpRequest, + ) -> Result> + { + self.inner.send_async(request).await + } + + async fn select( + &self, + pending: Vec, + ) -> Result> + { + self.collections.fetch_add(1, Ordering::SeqCst); + futures::future::poll_fn(|_| { + if self.released.load(Ordering::SeqCst) { + std::task::Poll::Ready(()) + } else { + std::task::Poll::Pending + } + }) + .await; + self.inner.select(pending).await + } + } + + struct GatedAuctionProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for GatedAuctionProvider { + fn provider_name(&self) -> &'static str { + "seam-test" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + context + .services + .http_client() + .send_async(crate::platform::PlatformHttpRequest::new( + Request::builder() + .uri("https://bidder.example.com/bid") + .body(EdgeBody::empty()) + .expect("should build test bid request"), + "seam-test", + )) + .await + .change_context(TrustedServerError::Auction { + message: "Failed to dispatch test auction".to_string(), + }) + .map(ProviderRequestOutcome::pending) + } + + async fn parse_response( + &self, + _response: crate::platform::PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + "seam-test", + Vec::new(), + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 60_000 + } + } + + #[test] + fn parser_confirmed_auction_seam_streams_nextjs_for_every_encoding() { + for encoding in ["", "gzip", "deflate", "br"] { + let mut settings = create_test_settings(); + settings.auction.enabled = true; + settings.auction.providers = + crate::auction::AuctionConfig::legacy_provider_map(&["seam-test"]); + settings.auction.timeout_ms = 60_000; + settings.auction.mediator = None; + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + }), + ) + .expect("should enable Next.js"); + let client = Arc::new(GatedAuctionHttpClient { + inner: StubHttpClient::new(), + released: std::sync::atomic::AtomicBool::new(false), + collections: AtomicUsize::new(0), + }); + client.inner.push_response(200, Vec::new()); + let services = build_services_with_http_client(Arc::clone(&client) as _); + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + orchestrator.register_provider(Arc::new(GatedAuctionProvider)); + let request = Request::new(EdgeBody::empty()); + let dispatched = futures::executor::block_on(orchestrator.dispatch_auction( + &test_auction_request(), + &AuctionContext { + settings: &settings, + request: &request, + timeout_ms: 60_000, + transport_timeout_ms: 60_000, + provider_responses: None, + services: &services, + }, + )); + let crate::auction::orchestrator::DispatchAuctionOutcome::Dispatched(dispatched) = + dispatched + else { + panic!("should dispatch a pending auction"); + }; + let payload = r#"{"url":"https://origin.example.com/path","text":""}"#; + let split = payload.find("/path").expect("should find payload split"); + let first_payload = format!("1:T{:x},{}", payload.len(), &payload[..split]); + let first_script = + serde_json::to_string(&first_payload).expect("should encode first payload"); + let second_script = + serde_json::to_string(&payload[split..]).expect("should encode second payload"); + let prefix = format!( + "

before RSC

between scripts
still streaming
" + ); + let page = format!("{prefix}"); + let encoded = match encoding { + "gzip" => [ + gzip_encode(prefix.as_bytes()), + gzip_encode(b""), + ] + .concat(), + "deflate" => deflate_encode(page.as_bytes()), + "br" => brotli_encode(page.as_bytes()), + _ => page.as_bytes().to_vec(), + }; + let params = html_stream_params(encoding, Some(dispatched)); + let state = params.ad_bids_state.clone(); + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let response = Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .body(EdgeBody::empty()) + .expect("should build response"); + let response = futures::executor::block_on(publisher_response_into_streaming_response( + PublisherResponse::Stream { + response, + body: EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from( + encoded, + )])), + params: Box::new(params), + }, + &Method::GET, + Arc::new(settings), + ®istry, + Arc::new(orchestrator), + services, + )) + .expect("should create lazy response"); + let mut stream = response + .into_body() + .into_stream() + .expect("should retain lazy body"); + let waker = futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + let mut output = Vec::new(); + loop { + match futures::Stream::poll_next(stream.as_mut(), &mut context) { + std::task::Poll::Ready(Some(Ok(chunk))) => output.extend_from_slice(&chunk), + std::task::Poll::Pending => break, + other => { + panic!("should wait on the unresolved auction for {encoding}: {other:?}") + } + } + } + let mut decoder = + BodyStreamDecoder::new(Compression::from_content_encoding(encoding), 1024 * 1024); + let decoded = decoder + .decode_chunk(bytes::Bytes::from(output.clone())) + .expect("should decode flushed prefix"); + let prefix = String::from_utf8(decoded.to_vec()).expect("should decode UTF-8 prefix"); + assert!( + prefix.contains("still streaming") && prefix.contains(""), + "should emit false literal and later article before auction completes for {encoding}: {prefix}" + ); + assert!( + prefix.contains("proxy.example.com") && !prefix.contains("origin.example.com"), + "should rewrite the split RSC group for {encoding}: {prefix}" + ); + assert!( + !prefix.contains("var b=JSON.parse("), + "should hold bids until auction completes" + ); + assert_eq!( + client.collections.load(Ordering::SeqCst), + 1, + "should begin collection at the real seam" + ); + assert!( + state + .script_cell() + .lock() + .expect("should lock bids") + .is_none(), + "should keep auction unresolved at seam" + ); + client.released.store(true, Ordering::SeqCst); + futures::executor::block_on(async { + while let Some(chunk) = futures::StreamExt::next(&mut stream).await { + output.extend_from_slice(&chunk.expect("should stream completed auction")); + } + }); + let decoded = match encoding { + "gzip" => gzip_decode(&output), + "deflate" => deflate_decode(&output), + "br" => brotli_decode(&output), + _ => output, + }; + let html = String::from_utf8(decoded).expect("should emit UTF-8 HTML"); + let bids = html + .find("var b=JSON.parse(") + .expect("should inject collected bids"); + let close = html + .rfind("") + .expect("should retain real body close"); + assert!( + html.find("still streaming").expect("should retain article") < bids && bids < close, + "should inject bids only before the real close for {encoding}" + ); + assert_eq!( + html.matches("var b=JSON.parse(").count(), + 1, + "should inject once" + ); + assert!( + !html.contains("ts-inline-body-close-") && !html.contains("__ts_rsc_"), + "should remove internal markers for {encoding}: {html}" + ); + assert_eq!( + client.collections.load(Ordering::SeqCst), + 1, + "should collect once" + ); + } + } + // (method, status, expected Content-Length, expected Transfer-Encoding) // after bodiless normalization. 204 forbids Content-Length (removed); 205 // must advertise a zero-length body; HEAD and 304 legitimately advertise the @@ -18523,12 +19269,11 @@ mod tests { ); } - /// Streaming dispatch contract: HTML with a registered post-processor still - /// routes through `Stream`, and the shared processor pipeline still applies - /// the post-processor rewrite. + /// Streaming dispatch contract: HTML with a registered stream processor + /// routes through `Stream`, and the shared processor pipeline applies it. #[test] - fn streaming_html_with_post_processors_rewrites_body() { - // Configure nextjs so a post-processor is registered. + fn streaming_html_with_stream_processors_rewrites_body() { + // Configure nextjs so a stream processor is registered. let mut settings = create_test_settings(); settings .integrations @@ -18551,8 +19296,8 @@ mod tests { .expect("should create integration registry"); assert!( - registry.has_html_post_processors(), - "nextjs integration must register an HTML post-processor" + !registry.html_stream_processor_factories().is_empty(), + "nextjs integration must register an HTML stream processor" ); assert_eq!( classify_response_route( @@ -18562,7 +19307,7 @@ mod tests { "proxy.example.com", ), ResponseRoute::Stream, - "HTML with post-processors must route to Stream" + "HTML with stream processors must route to Stream" ); // Feed a small HTML body through the same pipeline the Stream arm uses. @@ -18609,13 +19354,12 @@ mod tests { ); } - /// Document-state survives from the streaming pass into the post-processor. + /// Document-state survives from the parser pass into the stream processor. /// `NextJsRscPlaceholderRewriter` writes into `IntegrationDocumentState` - /// during streaming; `NextJsHtmlPostProcessor` reads it and substitutes. - /// Regression test: with post-processors registered, placeholders must - /// be inserted during streaming and substituted out of the final output. + /// during parsing; the request-local stream processor reads it and substitutes. + /// Regression test: placeholders must be inserted and removed from final output. #[test] - fn document_state_placeholders_substitute_through_accumulating_path() { + fn document_state_placeholders_substitute_through_streaming_path() { let mut settings = create_test_settings(); settings .integrations diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 89f73534d..1c6eb74d8 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -58,3 +58,320 @@ pub mod tests { pub const VALID_SYNTHETIC_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.Ab1234"; } + +/// Shared Next.js + auction origin fixture. +/// +/// Adapters exercise the buffered publisher path against this fixture in their +/// own route tests, and the cross-adapter parity suite reuses it, so all four +/// drive byte-identical input. +#[cfg(any(test, feature = "test-utils"))] +pub mod nextjs_auction { + use std::net::IpAddr; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use error_stack::Report; + + use crate::geo::GeoInfo; + use crate::platform::{ + BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, + PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, + RuntimeServices, StoreId, StoreName, UnavailableKvStore, + }; + use crate::settings::Settings; + + /// Publisher host the fixture settings serve. + pub const PUBLISHER_HOST: &str = "test-publisher.example.com"; + /// Upstream host the fixture origin answers for. + pub const ORIGIN_HOST: &str = "origin.test-publisher.example.com"; + /// Auction endpoint host the fixture bidder answers for. + pub const AUCTION_HOST: &str = "auction.example.com"; + + /// Settings that enable the Next.js integration and a single auction provider. + /// + /// # Panics + /// + /// Panics if the embedded TOML is invalid. + #[must_use] + pub fn settings() -> Settings { + let mut settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "fixture-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse Next.js auction fixture settings"); + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + }), + ) + .expect("should enable the fixture Next.js integration"); + settings.auction.enabled = true; + settings.auction.mediator = None; + settings.auction.providers = serde_json::from_value(serde_json::json!({ + "fixture": { + "protocol": "openrtb-2.6", + "endpoint": "https://auction.example.com/bid", + "routing": "all_eligible", + "timeout_ms": 5000 + } + })) + .expect("should configure the fixture auction provider"); + settings.creative_opportunities = Some( + toml::from_str( + r#" + gam_network_id = "12345" + [[slot]] + id = "fixture-slot" + page_patterns = ["/article"] + formats = [{ width = 300, height = 250 }] + "#, + ) + .expect("should parse fixture creative opportunities"), + ); + settings + } + + /// Flight payload content the fixture must carry once its origin URL has been + /// rewritten to the proxy host. + const REWRITTEN_FLIGHT_CONTENT: &str = + r#"{"url":"http://test-publisher.example.com/app","text":""}"#; + + /// The complete rewritten Flight payload, with the `T` length recomputed for + /// the shortened URL. + /// + /// The fixture deliberately splits the URL across two scripts, so this never + /// appears contiguously in the HTML: a caller that cannot parse the DOM must + /// assert on [`expected_rewritten_flight_header`] instead. + #[must_use] + pub fn expected_rewritten_flight_payload() -> String { + format!( + "1:T{:x},{REWRITTEN_FLIGHT_CONTENT}", + REWRITTEN_FLIGHT_CONTENT.len() + ) + } + + /// The `id:Tlength,` header of the rewritten payload. + /// + /// The declared length shrinks when the origin URL is replaced by the shorter + /// proxy URL, so this header changes if rewriting silently stops happening. + #[must_use] + pub fn expected_rewritten_flight_header() -> String { + format!("1:T{:x},", REWRITTEN_FLIGHT_CONTENT.len()) + } + + /// Origin HTML whose Flight payload spans two scripts and contains a literal + /// `` inside RSC data, so a parser-blind body seam would fire early. + /// + /// # Panics + /// + /// Panics if the embedded fixture content cannot be split. + #[must_use] + pub fn origin_html() -> String { + let content = r#"{"url":"https://origin.test-publisher.example.com/app","text":""}"#; + let split = content.find("/app").expect("should locate content split"); + let first = serde_json::json!(format!("1:T{:x},{}", content.len(), &content[..split])); + let second = serde_json::json!(&content[split..]); + format!( + "

prefix

suffix

" + ) + } + + /// Upstream that serves [`origin_html`] and one deterministic bid. + #[derive(Default)] + pub struct NextJsAuctionOrigin { + auction_requests: AtomicUsize, + } + + impl NextJsAuctionOrigin { + /// Number of auction requests this fixture has answered. + #[must_use] + pub fn auction_requests(&self) -> usize { + self.auction_requests.load(Ordering::SeqCst) + } + + /// Forget the recorded auction requests. + pub fn reset_auction_requests(&self) { + self.auction_requests.store(0, Ordering::SeqCst); + } + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for NextJsAuctionOrigin { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let (content_type, body) = match request.request.uri().host() { + Some(ORIGIN_HOST) => ("text/html", origin_html()), + Some(AUCTION_HOST) => { + self.auction_requests.fetch_add(1, Ordering::SeqCst); + ( + "application/json", + serde_json::json!({ + "id": "fixture-auction", + "seatbid": [{"seat": "example", "bid": [{ + "id": "fixture-bid", "impid": "fixture-slot", "price": 1.25, + "adm": "
fixture-creative
", "w": 300, "h": 250, + "crid": "example-creative", "adomain": ["advertiser.example.com"] + }]}] + }) + .to_string(), + ) + } + host => { + return Err(Report::new(PlatformError::HttpClient) + .attach(format!("unexpected fixture upstream: {host:?}"))); + } + }; + Ok(PlatformResponse::new( + http::Response::builder() + .status(200) + .header("content-type", content_type) + .body(edgezero_core::body::Body::from(body)) + .expect("should build deterministic upstream response"), + ) + .with_backend_name(request.backend_name)) + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let backend = request.backend_name.clone(); + Ok(PlatformPendingRequest::new(request).with_backend_name(backend)) + } + + async fn select( + &self, + mut pending_requests: Vec, + ) -> Result> { + let request = pending_requests + .remove(0) + .downcast::() + .expect("should recover fixture pending request"); + Ok(PlatformSelectResult { + ready: self.send(request).await, + remaining: pending_requests, + failed_backend_name: None, + }) + } + } + + struct FixtureGeo; + + impl PlatformGeo for FixtureGeo { + fn lookup( + &self, + _client_ip: Option, + ) -> Result, Report> { + Ok(Some(GeoInfo { + country: "AU".to_owned(), + city: "Example City".to_owned(), + continent: "Oceania".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + })) + } + } + + struct FixtureStore; + + impl PlatformConfigStore for FixtureStore { + fn get( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + impl PlatformSecretStore for FixtureStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + impl PlatformBackend for FixtureStore { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Axum + } + + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("fixture-backend".to_owned()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("fixture-backend".to_owned()) + } + } + + /// Runtime services wired to `client`, with every other platform capability + /// stubbed out. + #[must_use] + pub fn services(client: Arc) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(FixtureStore)) + .secret_store(Arc::new(FixtureStore)) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(FixtureStore)) + .http_client(client) + .geo(Arc::new(FixtureGeo)) + .client_info(ClientInfo::default()) + .build() + } +} diff --git a/crates/trusted-server-integration-tests/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index 7477fdbd1..39f985860 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -24,9 +24,10 @@ workspace = true edgezero-core = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } -trusted-server-core = { workspace = true } +trusted-server-core = { workspace = true, features = ["test-utils"] } [dev-dependencies] +async-trait = { workspace = true } axum = { workspace = true } bytes = { workspace = true } derive_more = { workspace = true } diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..c466e2036 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -8,6 +8,8 @@ // Both adapters define `TrustedServerApp` — alias both to avoid name collision. // axum::http re-exports from the `http` crate, so HeaderMap types are identical. +use std::sync::Arc; + use axum::body::Body as AxumBody; use axum::http::Request as AxumRequest; use edgezero_adapter_axum::service::EdgeZeroAxumService; @@ -19,6 +21,7 @@ use trusted_server_adapter_axum::app::TrustedServerApp as AxumApp; use trusted_server_adapter_cloudflare::app::TrustedServerApp as CloudflareApp; use trusted_server_adapter_spin::app::TrustedServerApp as SpinApp; use trusted_server_core::settings::Settings; +use trusted_server_core::test_support::nextjs_auction; /// Shared test settings for all adapters. /// @@ -919,3 +922,134 @@ async fn legacy_admin_aliases_are_denied_locally_not_proxied() { } } } + +/// A known non-regulated location permits the fixture's server-side auction. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn adapter_buffers_nextjs_auction_output() { + let client = Arc::new(nextjs_auction::NextJsAuctionOrigin::default()); + let settings = nextjs_auction::settings(); + let services = nextjs_auction::services(Arc::clone(&client)); + let routers = [ + ( + "Axum", + AxumApp::routes_with_settings_and_services(settings.clone(), services.clone()), + ), + ( + "Cloudflare", + CloudflareApp::routes_with_settings_and_services(settings.clone(), services.clone()), + ), + ( + "Spin", + SpinApp::routes_with_settings_and_services(settings, services), + ), + ]; + let mut expected_html = None; + for (adapter, router) in routers { + // Reset per adapter so each count is independently meaningful rather + // than a running total that a positional assertion cannot distinguish. + client.reset_auction_requests(); + let request = request_builder() + .method("GET") + .uri("https://test-publisher.example.com/article") + .header("host", "test-publisher.example.com") + .header("accept", "text/html") + .body(edgezero_core::body::Body::empty()) + .expect("should build publisher navigation"); + let response = router + .expect("should build router with fixed services") + .oneshot(request) + .await + .expect("should serve publisher navigation"); + assert_eq!( + response.status(), + 200, + "{adapter} should serve fixture HTML" + ); + let body = response + .into_body() + .into_bytes() + .expect("should buffer adapter output"); + let html = String::from_utf8(body.to_vec()).expect("should emit UTF-8 HTML"); + assert_eq!( + client.auction_requests(), + 1, + "{adapter} should dispatch exactly one auction" + ); + let document = scraper::Html::parse_document(&html); + let scripts = scraper::Selector::parse("script").expect("should parse script selector"); + let payload: String = document + .select(&scripts) + .filter_map(|script| { + let text: String = script.text().collect(); + let array = text + .strip_prefix("self.__next_f.push(")? + .strip_suffix(')')?; + let push: serde_json::Value = + serde_json::from_str(array).expect("should retain valid Flight push JSON"); + Some( + push[1] + .as_str() + .expect("should retain Flight string payload") + .to_owned(), + ) + }) + .collect(); + assert_eq!( + payload, + nextjs_auction::expected_rewritten_flight_payload(), + "{adapter} should rewrite the URL and T length while preserving complete payload bytes" + ); + assert!( + !html.contains("origin.test-publisher.example.com/app"), + "{adapter} should remove the origin URL" + ); + let first = html + .find("self.__next_f.push") + .expect("should retain first RSC script"); + let between = html + .find("window.between=true") + .expect("should retain intervening script"); + let last = html + .rfind("self.__next_f.push") + .expect("should retain last RSC script"); + assert!( + first < between && between < last, + "{adapter} should preserve script order" + ); + let bids = html + .find("var b=JSON.parse(") + .expect("should inject auction bids"); + let suffix = html.find("

suffix

").expect("should retain suffix"); + let close = html + .rfind("") + .expect("should retain structural close"); + assert!( + suffix < bids && bids < close, + "{adapter} should inject bids at the structural body close" + ); + assert!( + html.contains("fixture-creative"), + "{adapter} should include deterministic auction creative" + ); + assert!( + html[bids..].ends_with(""), + "{adapter} should place bid markup immediately before the body close" + ); + assert!( + !html.contains("__ts_rsc_") && !html.contains(""; +let mut seam = InlineBodyCloseSeam::new(token.to_vec()); + +let first = seam.push(b""); +assert!(!first.found, "publisher body text must not trigger the seam"); + +let second = seam.push(b"article"); +assert!(second.found, "the exact parser token should trigger the seam"); +assert_eq!(second.ready, b"article"); +assert_eq!(second.tail, b""); +``` + +Split the exact token at every byte boundary. Also prove a different UUID-shaped token is ordinary output and released before the seam. + +- [ ] **Step 3: Run focused tests and verify red** + +```bash +cargo test-fastly deferred_inline_marker -- --nocapture +cargo test-fastly inline_body_close_seam -- --nocapture +``` + +Expected: compilation fails because the deferred variant and exact-token controller do not exist. + +- [ ] **Step 4: Implement the parser variant** + +Add the distinct enum variant and handle it in the existing `body` end-tag callback by inserting its markup verbatim. Keep `InlineBids` for paths whose auction has already completed and stable `Marker` for ESI templates. Update exhaustive matches and documentation. + +- [ ] **Step 5: Generate one token with the processor construction result** + +Change `PublisherBodyProcessor` construction to accept an explicit immediate/deferred inline-seam mode. When deferred inline injection is valid, generate: + +```rust +format!( + "", + uuid::Uuid::new_v4().simple() +) +``` + +Store the bytes on `PublisherBodyProcessor` and pass the same string into `HtmlProcessorConfig`. Add an accessor which transfers or clones this exact token to the auction driver. ESI mode ignores deferred input and retains `TEMPLATE_SEAM_PLACEHOLDER`. + +- [ ] **Step 6: Implement `InlineBodyCloseSeam`** + +Replace `BodyCloseHoldBuffer` with an exact-token scanner over processed bytes. Return a value such as: + +```rust +struct SeamChunk { + ready: Vec, + tail: Vec, + found: bool, +} +``` + +In searching state, retain only a suffix which could begin the exact token. On a match, remove the token, return preceding bytes as ready and following bytes as tail, then enter released state. `finish` returns the candidate suffix unchanged when no token exists. Delete `BODY_CLOSE_PREFIX` and `find_ascii_case_insensitive` if no non-auction caller uses them. + +- [ ] **Step 7: Run focused suites and target gate** + +```bash +cargo test-fastly deferred_inline_marker -- --nocapture +cargo test-fastly inline_body_close_seam -- --nocapture +cargo test-fastly marker_mode -- --nocapture +cargo fmt --all -- --check +cargo test-fastly +``` + +Expected: parser and exact-token scanner tests pass; stable ESI marker tests remain green. + +- [ ] **Step 8: Commit seam primitives** + +```bash +git add crates/trusted-server-core/src/html_processor.rs \ + crates/trusted-server-core/src/publisher.rs +git commit -m "Mark inline body seams through the HTML parser" +``` + +--- + +### Task 6: Move auction holding after HTML processing + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs:774-1150,2280-2550,3507-4055,15500-15695,17130-17690,18035-18140` +- Test: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Write the decisive red streaming-order regression** + +Use the existing pending-auction/lazy-body fixtures. Feed at least three logical regions: + +1. head/body prefix; +2. a Next.js-style script containing a false `` plus later article markup; +3. the structural body close. + +Poll while the auction remains pending and assert region 2 and its later article bytes are available. Then assert the stream becomes pending only at the parser token. Complete the auction and assert the bid script is immediately before the structural close. + +The test must not use elapsed-time thresholds or sleeps. For the write-sink path, use an +observing writer whose `write` and `flush` calls are recorded separately. Before resolving +the auction, assert that region 2 was written, `flush()` was called after that write, and +collection has not started. + +- [ ] **Step 2: Add EOF and failure-path red tests** + +Cover no explicit body end, parser failure, source read failure, decoder failure, and encoder failure. Assert one terminal collect/abandon outcome and no generated token. Add an authorized ESI cold-miss test proving transform reaches EOF before auction collection and collects once before reader assembly. + +- [ ] **Step 3: Run focused tests and verify red** + +```bash +cargo test-fastly parser_confirmed_auction_seam -- --nocapture +cargo test-fastly esi_cold_miss_collects_after_transform -- --nocapture +``` + +Expected: old raw scanner stalls at the script literal or new test helpers are not yet wired. + +- [ ] **Step 4: Refactor chunk steps to process before seam detection** + +Replace the old sequence: + +```text +decode -> raw hold -> process -> encode +``` + +with: + +```text +decode -> process -> exact generated seam -> encode +``` + +Refactor `hold_step_decoded_chunk`, `hold_collect_close_tail`, `hold_finish_ready_segments`, and `hold_finish_tail_segments` around `InlineBodyCloseSeam`. Keep shared step/finish helpers for the lazy stream and write-sink driver. + +The step result must separate ready encoded segments from the seam event. The lazy caller +yields every ready segment before `collect_stream_auction(...).await`. A write-sink caller +writes every ready segment and calls synchronous `Write::flush()` successfully before starting +collection; propagate a flush failure through the same one-terminal-outcome guard as write, +decode, process, and encode failures. + +At source EOF, first finalize the decoder and HTML processor, feed every final processed +byte through `InlineBodyCloseSeam`, and expose its ready output. If no token was found, +release the seam's retained candidate suffix before awaiting collection, then collect once +for telemetry without injecting bids, and only then finalize the encoder trailer. This +preserves streaming for malformed/bodyless documents and prevents a token emitted during +`lol_html::end()` from being missed. + +- [ ] **Step 5: Wire deferred construction only when a controller exists** + +In `publisher_response_into_streaming_response` and `stream_publisher_body_async`, request a deferred inline token only when all are true: + +- an auction is still dispatched; +- content is HTML; +- `effective_assembly_mode` is inline; +- inline body injection is enabled for the response. + +Already-collected paths retain `InlineBids`; no-auction paths emit no token. Assert at construction that a deferred processor and controller either both have the same token or both have none. + +- [ ] **Step 6: Route authorized ESI cold misses through no-hold processing** + +When `template_cache_key.is_some()`, process the entire reader-neutral transform without an inline seam controller. Preserve the existing order of template validation/store and per-reader assembly, but collect the dispatched auction after transform completion and before substituting current-reader bid content. A cache-gate rejection already becomes inline through `effective_assembly_mode` and must use the deferred inline path. + +- [ ] **Step 7: Remove raw close-body orchestration** + +Delete `BodyCloseHoldBuffer`, `BODY_CLOSE_PREFIX`, raw close tests, and comments describing pre-parser `` scanning. Rename `AuctionHoldState` and helper names to refer to inline seams rather than raw body-close holds. Keep abandonment guard behavior and telemetry reason strings stable unless a test demonstrates they are misleading. + +- [ ] **Step 8: Run publisher regressions** + +```bash +cargo test-fastly parser_confirmed_auction_seam -- --nocapture +cargo test-fastly streaming_finalize_auction -- --nocapture +cargo test-fastly publisher_response_streaming_finalize -- --nocapture +cargo test-fastly template_cache_end_to_end_tests -- --nocapture +``` + +Expected: false literals stream before collection, real seams stall and inject once, EOF/failures terminate once, and ESI behavior remains correct. + +- [ ] **Step 9: Run the target gate and commit** + +```bash +cargo fmt --all -- --check +cargo test-fastly +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Resolve auctions at parser-confirmed body seams" +``` + +Expected: all Fastly/core tests pass before the commit. + +--- + +### Task 7: Complete compression, adapter, and documentation regressions + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs:16650-16895,17130-17690,18035-18430` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/mod.rs:285-710` +- Modify: `crates/trusted-server-adapter-axum/src/app.rs:115-215,575-645` +- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs:120-230,330-630` +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-spin/src/app.rs:80-170,460-850` +- Modify: `crates/trusted-server-adapter-spin/tests/routes.rs` +- Modify: `docs/guide/integrations/nextjs.md` +- Test: existing adapter/core test modules only + +- [ ] **Step 1: Add compressed parser-seam cases** + +Extend existing identity/gzip/deflate/Brotli publisher tests so the decoded HTML contains a +false script literal before the structural close. For every encoding, keep the auction +pending, poll the output, and assert that decoded prefix bytes through the false literal are +observable before collection begins. Then resolve the auction and assert decoded final-byte +parity, bid placement, valid encoder trailers, and no token leakage. For gzip, retain +multi-member coverage with the script and close in different members. + +- [ ] **Step 2: Add full-pipeline Next.js plus auction coverage** + +Enable Next.js and a pending auction together. Assert: + +- HTML before the first RSC group streams; +- a false `` inside `__next_f` does not collect; +- a bounded content-split group resolves in order; +- the real parser seam triggers collection; +- final output has rewritten URLs, corrected T length, bids before ``, and no placeholders. + +- [ ] **Step 3: Run focused compressed and combined tests** + +```bash +cargo test-fastly streaming_finalize_auction_hold -- --nocapture +cargo test-fastly stream_publisher_body_async_processes_ -- --nocapture +cargo test-fastly nextjs -- --nocapture +``` + +Expected: identity and all supported encodings preserve content/trailers; combined Next.js/auction streaming passes. + +- [ ] **Step 4: Update the Next.js guide** + +Document that ordinary HTML streams immediately, unresolved cross-script T-chunk groups are bounded, `max_combined_payload_bytes` limits payload and held output independently, and invalid/incomplete/over-limit groups are restored unchanged. Remove wording that implies guaranteed rewriting under fallback or full-document EOF post-processing. + +- [ ] **Step 5: Add red adapter parity route tests** + +Add one buffered route regression in each adapter test module. Build a fake +`PlatformHttpClient` that returns the same Next.js origin fixture and deterministic auction +response, place it in a complete `RuntimeServices` built with the existing public builder, +and attempt to construct each real router with those services. Assert the complete body +matches the core expected bytes: rewritten RSC URL and length, preserved script order, bid +markup immediately before structural ``, and no generated seam or RSC placeholder. +These are final-byte assertions because these adapters collect the core stream. + +**Amended during review:** the fixture is shared rather than copied three times. It lives in +`trusted_server_core::test_support::nextjs_auction` behind the existing `test-utils` feature, +which each adapter enables as a dev-dependency; the cross-adapter parity suite consumes the +same fixture. The per-adapter regressions are +`nextjs_auction_output_holds_until_the_structural_body_close` in each +`crates/trusted-server-adapter-{axum,cloudflare,spin}/tests/routes.rs`, so CI gate 3 covers +this path on every adapter. The cross-adapter byte-for-byte comparison stays in +`crates/trusted-server-integration-tests/tests/parity.rs` +(`adapter_buffers_nextjs_auction_output`), where it can compare adapters against each other. + +Run: + +```bash +cargo test-axum nextjs_auction_output -- --nocapture +cargo test-cloudflare nextjs_auction_output -- --nocapture +cargo test-spin nextjs_auction_output -- --nocapture +``` + +Expected: compilation fails because `routes_with_settings` always constructs platform +services internally and provides no injectable services seam. + +- [ ] **Step 6: Add a narrow injectable-services router seam** + +In each adapter's `app.rs`, add a private cloneable service source with two modes: + +```rust +#[derive(Clone)] +enum RuntimeServicesSource { + Platform, + Fixed(RuntimeServices), +} +``` + +Give it `for_request(&RequestContext) -> RuntimeServices`: production calls the adapter's +existing `build_runtime_services`, while `Fixed` clones the supplied services. Pass the +source into `build_router` and every handler factory/dispatch path that currently constructs +services. Keep `Hooks::routes()` and `routes_with_settings()` on `Platform`. Add a documented +`routes_with_settings_and_services(settings, services)` constructor on each adapter for +cross-crate integration tests; it builds the same `AppState` and router with `Fixed`. + +This seam changes dependency construction only. It must not expose +`OwnedProcessResponseParams`, duplicate core finalization, or alter production client-info +derivation. The injected fixture supplies the client metadata required by its request. + +- [ ] **Step 7: Run adapter parity suites** + +```bash +cargo test-axum +cargo test-cloudflare +cargo test-spin +``` + +Expected: native buffered adapters preserve final-byte behavior. + +- [ ] **Step 8: Run formatting and commit regressions/docs** + +```bash +cargo fmt --all -- --check +(cd docs && npm run format) +git add crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/integrations/nextjs/mod.rs \ + crates/trusted-server-adapter-axum/src/app.rs \ + crates/trusted-server-adapter-axum/tests/routes.rs \ + crates/trusted-server-adapter-cloudflare/src/app.rs \ + crates/trusted-server-adapter-cloudflare/tests/routes.rs \ + crates/trusted-server-adapter-spin/src/app.rs \ + crates/trusted-server-adapter-spin/tests/routes.rs \ + docs/guide/integrations/nextjs.md +git commit -m "Cover streaming seams across encodings and adapters" +``` + +Expected: Rust formatting and documentation formatting pass before the commit. If tests land in another touched source file, include that file explicitly in `git add`. + +--- + +### Task 8: Run the complete repository verification and review the diff + +**Files:** + +- Verify all files changed in Tasks 1-7 +- Update only files required to fix failures found by these gates + +- [ ] **Step 1: Confirm the raw scanner and EOF post-processor are gone** + +```bash +rg -n "BodyCloseHoldBuffer|BODY_CLOSE_PREFIX|find_ascii_case_insensitive|IntegrationHtmlPostProcessor|with_html_post_processor|html_post_processors" \ + crates/trusted-server-core/src +``` + +Expected: no production matches. Test names/comments should also use the new streaming terminology. + +- [ ] **Step 2: Confirm generated bytes cannot leak** + +Run the focused token/placeholder suites once more: + +```bash +cargo test-fastly inline_body_close_seam -- --nocapture +cargo test-fastly rsc_stream -- --nocapture +cargo test-fastly parser_confirmed_auction_seam -- --nocapture +``` + +Expected: all pass, including success, fallback, EOF, and error paths. + +- [ ] **Step 3: Run all Rust format and lint gates** + +```bash +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: every command exits zero with warnings denied. + +- [ ] **Step 4: Run all Rust test gates** + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +Expected: all suites pass with zero failures. + +- [ ] **Step 5: Run JS and documentation gates required by repository CI** + +```bash +(cd crates/trusted-server-js/lib && npx vitest run) +(cd crates/trusted-server-js/lib && node build-all.mjs) +(cd crates/trusted-server-js/lib && npm run format) +(cd docs && npm run format) +``` + +Expected: tests/build succeed and both format checks report no changes required. + +- [ ] **Step 6: Review scope and requirements against the spec** + +```bash +git diff main...HEAD --stat +git diff main...HEAD -- crates/trusted-server-core/src docs/guide/integrations/nextjs.md +git status --short +``` + +Verify line by line: + +- parser context is the only source of inline body seams; +- the ready prefix is yielded before auction await; +- Next.js no longer buffers every document to EOF; +- every accumulator and held group is checked before growth; +- unsafe RSC continuation enters immediate byte-preserving bypass; +- request state is isolated; +- ESI cold/warm and compression paths retain semantics; +- no unrelated refactor or generated build artifact is present; +- the worktree is clean after the final commit. + +- [ ] **Step 7: Request implementation code review** + +Use `superpowers:requesting-code-review` with `main` as the base and the current branch head. Address all Critical and Important findings, rerun the affected focused suite, and repeat the relevant full gate before claiming completion. + +- [ ] **Step 8: Commit only if verification required corrections** + +If Steps 1-7 required changes: + +```bash +git add +git commit -m "Resolve parser-seam verification findings" +``` + +If no files changed, do not create an empty commit. diff --git a/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md new file mode 100644 index 000000000..50dde7a9e --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md @@ -0,0 +1,655 @@ +# Parser-aware body hold and streaming-safe Next.js processing + +**Date:** 2026-09-07 + +**Status:** Proposed + +**Issue:** [IABTechLab/trusted-server#850](https://github.com/IABTechLab/trusted-server/issues/850) + +## 1. Decision + +Use `lol_html`'s structural `body` end-tag handler to place an internal, +request-unique control token in the transformed byte stream. Move the auction hold after +HTML parsing, recognize only that generated token, and replace it with the reader's bid +script after auction collection. Delete the pre-parser scan for the publisher-controlled +byte sequence ` + self.__next_f.push([1, 'text containing here']) + +``` + +causes the publisher stream to wait for the auction before the real body end. A match split +across origin chunks has the same result. + +The issue's original phrase "near-full-page hold" no longer precisely describes that +scanner in the current implementation. Once a match is found, the caller collects the +auction, processes the held tail, removes the hold, and resumes streaming. The false match +therefore causes an **early auction stall**, rather than retaining every later page byte. +The bug and its user-visible latency remain real. + +Separately, `HtmlWithPostProcessing` accumulates all transformed output until EOF whenever +any `IntegrationHtmlPostProcessor` is registered. Next.js is the only current registrant. +This remains a true whole-document buffer and delays all output even when a document has no +RSC payload to rewrite. + +The ESI work does not close #850. It already uses a `lol_html` body end-tag handler to place +`TEMPLATE_SEAM_PLACEHOLDER` at the structural seam, protecting cached templates from +`` strings in scripts and comments. The ordinary inline auction path still uses the +raw scanner, and the Next.js post-processor still buffers to EOF. This design applies the +same structural principle to inline delivery without changing ESI cache assembly. + +## 3. Goals + +1. Make `lol_html` the sole authority for choosing an inline HTML body-close seam. +2. Stream the transformed prefix while the auction is pending and wait only when the + parser-confirmed seam reaches the response controller. +3. Ensure publisher-controlled `` text can never trigger the auction wait. +4. Remove unconditional whole-document buffering when Next.js is enabled. +5. Preserve Next.js URL rewriting, script order, React hydration, and RSC T-chunk length + correction whenever a bounded group is complete. +6. Bound all Next.js deferral and degrade to byte-preserving output when safe rewriting is + impossible. +7. Preserve compression, error, telemetry, CSP, and ESI template-cache behavior. + +## 4. Non-goals + +- Making authorized ESI template-cache fills stream. A complete transformed document is + intentionally required before cache validation and insertion. +- Changing warm ESI template assembly or its stable `AD_ASSEMBLY_SEAM` format. +- Adding streaming response support to adapters that do not currently expose it. +- Expanding which Next.js attributes or URL shapes are rewritten. +- Changing the standalone `text/x-component` RSC Flight response processor. +- Adding another HTML tokenizer or parsing the document a second time. +- Guaranteeing a rewrite for malformed or over-limit RSC data. Hydration-safe unchanged + output takes priority in those cases. + +## 5. Considered approaches + +### A. Parser-generated control seam plus streaming integration sessions — selected + +The existing parser inserts a private token at the structural body end. A post-parser +controller recognizes the token and coordinates the asynchronous auction. Next.js uses a +per-response streaming session and defers only an unresolved RSC group. + +This keeps one HTML parser, makes the async wait occur outside synchronous `lol_html` +callbacks, preserves output order, and limits changes to the core HTML/publisher pipeline +and Next.js registration. + +### B. Return structural events from `StreamProcessor` + +Change `StreamProcessor::process_chunk` to return bytes plus typed events and exact output +offsets. This avoids a generated byte token, but every processor, compression driver, and +caller must adopt the new result type. It still needs an ordered Next.js deferral layer. +The additional surface is not justified for the one asynchronous seam. + +### C. Tokenize before the existing HTML processor + +Run another HTML tokenizer over origin bytes to locate `` before `lol_html` runs. +This duplicates parsing, must map an input boundary onto rewritten output, and can drift +from `lol_html` on malformed-but-renderable documents. It is rejected. + +## 6. Processing architecture + +For processable inline HTML on a streaming adapter, the body path becomes: + +```text +origin chunks + -> bounded decompressor + -> lol_html structural and integration rewrites + -> integration streaming output processors (Next.js when enabled) + -> inline auction seam controller + -> streaming compressor + -> client +``` + +The important ordering change is `lol_html` before the auction seam controller. The current +pipeline holds origin bytes and then parses them. The new pipeline parses first, so only a +control token emitted by the parser can cause a wait. + +Buffered adapters use the same processors and state transitions while writing into their +existing bounded output. They gain behavior parity and lose the redundant Next.js +whole-document accumulation, even though their platform response remains buffered. + +Authorized ESI cold misses continue through their existing bounded finalizer. Their parser +emits the stable template placeholder, the completed transform is validated and stored, +and the reader-specific seam is assembled as it is today. They do not use the inline +request-unique token. + +They also stop using the raw body-close hold. The stable template placeholder does not +depend on bid state, so the transform can run to completion while the auction remains in +flight. After transform and template validation, collect the auction before substituting +the current reader's seam content. The cold miss remains intentionally buffered for cache +insertion, but neither its parsing nor auction lifecycle depends on scanning for ``. +An ESI response whose template-cache gate was rejected follows the ordinary inline deferred +seam path because `effective_assembly_mode` already reduces it to inline delivery. + +## 7. Parser-confirmed inline auction seam + +### 7.1 Control token ownership + +When the response has a dispatched HTML auction and inline body injection is enabled, the +publisher creates one UUID-v4 token using the repository's existing WASM-compatible UUID +support. Its serialized form is an inert HTML comment: + +```text + +``` + +The token is request-private, never stored, and shared by exactly two components: + +- `HtmlProcessorConfig`, whose body end-tag handler emits it once; +- the auction seam controller, which recognizes and removes it. + +Use a distinct `BodyCloseInjection` variant for this token rather than overloading the ESI +`Marker` semantics. The ESI marker must be stable between requests; the inline token must +be unique to one response. + +Select the deferred variant only when orchestration still owns a dispatched auction that +must run concurrently with body processing. Paths which have already collected the auction +retain immediate `InlineBids` insertion. Paths with neither a pending nor completed inline +auction emit no inline token. This prevents any constructor that lacks a seam controller +from producing an unresolved token. + +Represent this choice explicitly at processor construction, for example as an immediate or +deferred inline seam mode. `PublisherBodyProcessor` must expose the generated deferred token +to its async driver; callers must not independently generate a second value. The exact Rust +type is an implementation-plan decision, but one construction result must own both the +configured parser and the matching controller token. + +Publisher bytes cannot predict the generated value. A source document containing the +fixed prefix or another response's token is ordinary content. The implementation must +match the complete current-response token and retain at most `token.len() - 1` candidate +bytes between processed chunks. + +### 7.2 Parser behavior + +The existing `element!("body", ...)` handler remains the structural hook. For the inline +deferred variant, its first available end-tag handler inserts the token immediately before +the structural ``. The existing single-injection guard continues to handle documents +with multiple body elements. + +The handler no longer reads `ad_bids_state` for deferred inline delivery. Synchronous +parser work only identifies the seam; asynchronous auction collection and bid construction +remain in publisher orchestration. + +If `lol_html` exposes no body end tag because the body is implicit, truncated, or absent, +the handler emits no token. The existing diagnostic warning remains appropriate when the +server-side ad path expected an insertion point. + +### 7.3 Seam-controller state machine + +The controller has three states: + +1. **Searching:** stream all processed bytes except the suffix that could begin the exact + token. +2. **Found:** return the prefix before the token to the caller. The caller must make that + prefix available to the client before awaiting auction collection. After collection, + emit the bid script in place of the token, then stream the remaining bytes. +3. **Released:** pass every later processed byte through without scanning or copying. + +On processor EOF while still searching, release the retained candidate bytes, collect the +auction for completion and telemetry, finalize compression, and inject no bids. The token +must never be sent to the client. + +This state machine replaces `BodyCloseHoldBuffer`; no production code may search origin or +transformed HTML for the literal ` Result, io::Error> +``` + +It may retain a documented, bounded subset of output between calls. It must emit all +retained output or return an error at EOF. Generated control placeholders must never reach +the caller. + +`create_html_processor` constructs one `HtmlRewriterAdapter`, then wraps its output in the +registered sessions in registration order. With no streaming output processor, it returns +each `lol_html` output chunk immediately. Delete `HtmlWithPostProcessing`'s +`accumulated_output`, `decoded_input_len`, and EOF-wide post-processing branch. + +The registry methods and builder terminology change from `html_post_processor` to +`html_stream_processor`. This is crate-internal API and has only the Next.js consumer in +the current tree. + +All mutable script-fragment and placeholder state must live in +`IntegrationDocumentState` or the per-document session. Do not retain request-progress +buffers such as `NextJsNextDataRewriter::accumulated_text` on the registry-owned rewriter: +the registry is shared, so request interleaving could otherwise combine fragments from +different documents. Moving this state is required by the new session boundary, not a +separate integration refactor. + +### 8.2 Ordering and bounds + +Processors must preserve document byte order. A processor may withhold bytes after a +control placeholder when earlier content cannot yet be finalized, but it may not emit +later markup before that placeholder is resolved or restored. + +Every processor owns its own semantic limit. The outer publisher decoded-input and output +bounds remain defense in depth for buffered response paths; they do not justify an +unbounded integration session on streaming paths. + +## 9. Next.js RSC streaming design + +### 9.1 Parser-side capture + +Keep `NextJsNextDataRewriter`'s current per-script fragmentation behavior. Intermediate +`__NEXT_DATA__` text fragments are suppressed and accumulated; the complete text node is +rewritten or restored on `last_in_text_node`. Bound this per-script accumulator by +`publisher.max_buffered_body_bytes`. Before another fragment would exceed the bound, +replace the current fragment with all previously suppressed text plus the current fragment, +then pass the rest of that script through unchanged. This changes an oversized script from +"buffer then reject the whole response" to "preserve this script without rewriting" while +the surrounding document continues streaming. + +Store that accumulator in the current `IntegrationDocumentState`, keyed separately from +RSC group state. The immutable `NextJsNextDataRewriter` retains only configuration and its +compiled URL matcher. + +Model fragmented-script capture explicitly as: + +```text +Idle -> Buffering -> Idle + \ | + \ +-> BypassUntilLast -> Idle + +----------> BypassUntilLast -> Idle +``` + +- `Idle` plus a non-final fragment starts `Buffering` and suppresses the fragment when it + fits. A first fragment which already exceeds the bound is emitted unchanged and enters + `BypassUntilLast`. +- `Idle` plus a final in-bound fragment is processed directly and remains `Idle`. + `__NEXT_DATA__` rewrites or restores it; RSC classifies it and either emits a placeholder + or restores it. +- `Idle` plus a final over-limit fragment is emitted unchanged without first copying it into + an accumulator and remains `Idle`. For `__NEXT_DATA__`, the next script may be processed + normally. For RSC, inspect the borrowed fragment with the boundary-aware classifier: a + neutral, self-contained payload permits the next RSC script to be processed normally; + `NeedMore` or `Invalid` also enters document-wide byte-preserving RSC bypass because + later scripts may continue data whose header has already been emitted. +- `Buffering` appends and suppresses while the next fragment fits. Before overflow, emit + the accumulated prefix plus the current fragment unchanged and enter `BypassUntilLast`. +- `Buffering` plus a final in-bound fragment rewrites the complete script or restores it, + then returns to `Idle`. +- `BypassUntilLast` emits every fragment unchanged. It returns to `Idle` only after seeing + `last_in_text_node`. + +The RSC script accumulator uses the same state machine with +`max_combined_payload_bytes`. This prevents a final fragment from being classified or +rewritten independently after an oversized prefix has already been released. + +`BypassUntilLast` is per-script capture state. Document-wide RSC bypass is a separate flag: +an RSC overflow which occurs before the script is complete sets both, the per-script state +returns to `Idle` at the final fragment, and the document flag remains set through EOF. In +that mode, later complete RSC scripts are restored immediately rather than captured for +rewriting. `__NEXT_DATA__` overflow never sets the document-wide RSC flag. + +The RSC output session checks the shared document-wide bypass flag before handling each +new `lol_html` output chunk. If parser-side overflow sets it while an older unresolved group +is held, the session must perform one atomic transition before emitting the overflowing +script's output: + +1. append any separately retained partial placeholder-candidate suffix to the held output; +2. restore every queued placeholder in the held output and current chunk with its exact + captured original payload; +3. release the restored group, interstitial markup, and current chunk in document order; +4. clear the group FIFO, payload/output counters, T-chunk classifier, and placeholder + candidate state; +5. retain only the document-wide bypass flag through EOF and pass later RSC content through + unchanged. + +If a generated placeholder has no matching captured original during this transition, +return a processor error rather than leaking the placeholder or emitting a partially +restored script. This is an internal state-invariant failure, not malformed publisher data. + +Change the RSC script rewriter to use the same discipline. For each `script` text node: + +- accumulate and suppress fragments until `last_in_text_node`; +- restore non-RSC or unparseable script text exactly; +- for a recognized `self.__next_f.push([1, ...])`, store the original payload in + request-scoped state and emit a request-scoped placeholder for that payload range. + +Bound RSC per-script text accumulation by `max_combined_payload_bytes`. On overflow, restore +the suppressed prefix in the current text fragment, pass the rest of that script through, +and mark the document's RSC output session as bypassed. An incomplete payload cannot be +safely separated from later script continuations. + +Placeholder names include a per-document UUID namespace plus a monotonically increasing +index. A publisher string that resembles the fixed placeholder prefix is not actionable +without the current namespace. + +The factory and parser rewriter obtain the namespace from one shared per-document state +created before the first HTML chunk is processed. Namespace creation must be idempotent so +factory and handler construction order cannot produce different values. + +### 9.2 Output-session state + +The Next.js session owns: + +- the request origin and rewrite configuration; +- the placeholder namespace; +- a FIFO of captured original payloads; +- the output held behind the first unresolved placeholder; +- the combined payload byte count and total held-output byte count; +- whether one over-limit/incomplete warning has been emitted for the current group; +- whether RSC rewriting has entered byte-preserving bypass for the rest of the document. + +It scans only for its generated placeholder namespace. Non-candidate output streams with +at most `longest_placeholder_len - 1` bytes retained for chunk-boundary matching. + +### 9.3 Group resolution + +When the next placeholder is available in both output and captured state: + +1. Append its original payload to the current group. +2. Feed it to a boundary-aware T-chunk classifier. +3. If the classifier reports `CompleteRewritable`, run + `rewrite_rsc_scripts_combined_with_limit`, verify that the output count equals the input + count, substitute every group placeholder in order, and release the entire held segment. +4. If it reports `CompleteUnrewritable`, restore all originals, release the complete group, + and begin the next group normally. +5. If it reports `NeedMore`, retain the group and the following output until another RSC + payload arrives. +6. If it reports `Invalid`, restore the group and enter byte-preserving bypass for the rest + of the document because no safe continuation boundary is known. + +Do not use `find_tchunks_impl` or `rewrite_rsc_scripts_combined_with_limit` as the +completeness oracle. Their header regex recognizes only a complete +`[hex]+:T[hex]+,` sequence inside the current physical string. `RSC_MARKER` is inserted +between payloads, so a header split at that boundary is otherwise invisible and an empty +match set can be mistaken for a complete group. + +The classifier consumes the logical concatenation of payloads while retaining physical +payload boundaries. It uses an incremental state machine with these states: + +- `Neutral`: no open header or T-chunk content; +- `HeaderCandidate`: a suffix is a strict prefix of `[hex]+:T[hex]+,`; +- `Content { remaining_unescaped_bytes }`: a complete header declared content that has not + all arrived; +- `Invalid`: malformed length, unreasonable declared length, or inconsistent escape data. + +At a payload boundary, `HeaderCandidate` and nonzero `Content` both yield `NeedMore`. +Because a header may start at the end of one payload, a trailing hexadecimal run is treated +conservatively as a header candidate until the next payload disproves or completes it. The +classifier must count the same JavaScript escape forms and enforce the same +`MAX_REASONABLE_TCHUNK_LENGTH` rule as the rewriter. + +The current combined rewriter supports a complete header in one payload whose declared +content crosses later payloads. Such groups are `CompleteRewritable`. A header physically +split across payloads is `CompleteUnrewritable` once its content is complete: restore that +group unchanged because inserting `RSC_MARKER` inside the header makes the current rewriter +unsafe. Supporting rewritten split headers would require a boundary-mapped rewriter and is +outside #850; safe streaming fallback meets this design's hydration-first contract. + +A complete single-script payload in the classifier's `Neutral` state therefore releases on +the same processor call. A cross-script payload releases as soon as the script containing +its final declared byte has been parsed and the classifier returns to `Neutral`; it does +not wait for document EOF. A trailing header candidate may conservatively hold a payload +until the next RSC payload or EOF, subject to the same hard bounds. + +Content between grouped scripts must remain in the held segment. Although that may include +ordinary HTML, emitting it early would place it before an earlier executable script and +change document execution order. + +### 9.4 Safe fallback + +`integrations.nextjs.max_combined_payload_bytes` becomes the hard maximum for each of: + +- the sum of original payload bytes in one unresolved group; +- the transformed output bytes held behind that group. + +The configuration key and default remain unchanged. Its guide text must explain the +broader streaming-memory meaning. + +Before either counter would exceed the limit, replace every placeholder already held with +its exact original payload and release the group unchanged. If the group was incomplete or +invalid, enter byte-preserving RSC bypass for the rest of the document: every later RSC +placeholder is replaced immediately with its original payload and is never independently +rewritten. A later payload may be the continuation of the earlier T-chunk, so rewriting it +alone could change content without correcting the header already emitted. + +If a complete, internally valid group is restored only because its held-output size reached +the limit, clear that group and allow the next independent RSC group to be considered. In +either fallback mode, the processor must not repeatedly absorb an unbounded segment. + +At EOF, apply the same unchanged restoration to an incomplete or invalid group. If the +rewrite function returns a different payload count or any generated placeholder remains +after substitution, restore the entire group from originals instead of emitting partial +rewrites. Emit one warning with reason, payload count, and byte counts; never include +payload text or URLs. + +This fallback deliberately favors hydration over proxy URL coverage. It matches the +existing over-limit safety policy while ensuring that no publisher document is withheld +without a bound. + +"Unchanged" here means unchanged relative to the first-pass `lol_html` output with RSC URL +rewriting disabled. Other intentional transformations already performed by the HTML +processor remain present; the fallback must restore the exact captured RSC payload bytes +and preserve their surrounding serialized markup. + +### 9.5 Relationship to the auction seam + +The Next.js session runs before the inline seam controller. Normally it resolves RSC groups +before ``, allowing the body-close token to reach the auction controller at the +structural location. If an incomplete group extends to the body close, the Next.js EOF or +limit fallback restores and releases it; the token then triggers collection. No component +searches RSC content for `` at orchestration boundaries and `io::Error` + inside `StreamProcessor` implementations. +- A `lol_html` processing error abandons an outstanding auction exactly once. +- A stream read or compression error preserves the existing terminal telemetry reason. +- Missing body close is not a parsing error; it is the no-injection EOF path described + above. +- Next.js malformed, incomplete, count-mismatched, or over-limit data is restored unchanged + and logged once per affected group. +- Log completed cross-script RSC groups at debug level with payload count and byte counts. +- Never log HTML, RSC payloads, rewritten URLs, bid contents, or generated control tokens. + +No new metrics or response headers are required. Existing auction telemetry is sufficient +to observe completion and abandonment; streaming correctness is enforced by tests rather +than timing logs. + +## 11. Compatibility and security properties + +- HTML output without an auction or streaming integration remains byte-for-byte equivalent + to the current `lol_html` path. +- `__NEXT_DATA__`, non-RSC scripts, attribute rewriting, head injection, CSP nonce handling, + and DataDome suppression retain existing behavior. +- Per-document fragment state cannot cross-contaminate concurrent or interleaved responses. +- Inline and ESI seam tokens are distinct. An inline token is never cacheable; an ESI + template marker is never interpreted by the inline controller. +- UUID token generation uses an existing dependency and runtime capability; no new source + of randomness is introduced. +- A publisher cannot deliberately trigger the inline wait using a known fixed string. +- Generated tokens and placeholders are removed on success and fallback paths. +- The Next.js memory limit is checked before growing either retained buffer beyond the + configured ceiling. +- Per-script `__NEXT_DATA__` and RSC text accumulation is bounded before complete script + classification is available. +- Script order and text are preserved when rewriting is skipped, preventing hydration + corruption under adversarial or malformed payloads. + +## 12. Test and acceptance matrix + +### 12.1 Parser-aware body seam + +Add focused tests proving: + +- `` inside ordinary script text, `__next_f` payloads, JSON, escaped strings, and + comments does not report a seam; +- false literals split at every token boundary do not report a seam; +- mixed-case structural `` and a structural close split across origin chunks emit + exactly one generated control token through `lol_html`; +- a trailing comment containing `` cannot move the seam; +- multiple body elements still create one insertion; +- a document without an explicit body end emits no token and leaks no internal bytes. + +### 12.2 Auction delivery ordering + +Use the existing pending-auction stream fixtures rather than wall-clock sleeps: + +- poll a response whose script contains `` while the auction remains pending and + assert that output through and beyond that script is available; +- assert that polling stops only when the parser-generated seam is reached; +- complete the auction and assert that bids occur immediately before the structural body + end; +- split the generated token across processed chunks and obtain identical output; +- verify EOF-without-seam completes telemetry and injects no bids; +- verify read, parse, and encode failures abandon the auction once. + +These tests are the direct regression proof for #850. A final-output assertion alone is +insufficient because buffered and streaming implementations can produce identical bytes. + +### 12.3 Next.js streaming + +Add unit and pipeline tests for: + +- a Next.js-enabled document with no RSC scripts emits intermediate output before EOF; +- complete single-script RSC payloads rewrite and release without EOF; +- input-chunk fragmentation within an RSC script is accumulated and rewritten once; +- multiple independent payloads release independently in document order; +- a T-chunk split across two or more scripts updates the original header length and releases + immediately when complete, when the complete header is in the first payload; +- a T-chunk header split at every payload boundary is detected and restored unchanged, + never treated as an independently rewritable payload; +- non-RSC scripts and interstitial markup retain exact relative order; +- the payload-byte limit restores a group byte-for-byte; +- the held-output limit restores a group before exceeding the limit; +- invalid and EOF-incomplete T-chunks restore originals; +- an incomplete over-limit group forces later continuation scripts to remain unchanged; +- oversized fragmented `__NEXT_DATA__` and RSC scripts release suppressed text and stream + the rest unchanged; +- accumulator overflow followed by multiple fragments remains in `BypassUntilLast`, then a + subsequent independent script starts from `Idle` and can be rewritten; +- one-fragment over-limit `__NEXT_DATA__` is emitted unchanged and leaves the next script in + `Idle`; +- one-fragment over-limit RSC is emitted without an over-limit copy, and incomplete or + invalid content forces later RSC scripts to remain unchanged; +- an unresolved group followed by either fragmented or one-fragment RSC overflow is + restored and released in the same output-session call, before the overflowing script, + with all group and partial-placeholder state cleared; +- two interleaved processor instances never share `__NEXT_DATA__`, RSC payload, namespace, + or bypass state; +- rewrite count mismatch and placeholder-remnant safeguards restore originals; +- no placeholder namespace appears in any final output; +- `__NEXT_DATA__` fragmentation and configured-attribute behavior remain unchanged. + +### 12.4 Compression, ESI, and adapters + +- Exercise identity, gzip, deflate, and Brotli through the new parse -> seam -> encode order, + including trailer preservation and multi-member gzip coverage already present in the + publisher suite. +- Retain and run ESI cold-miss, warm-hit, publisher-collision, script-literal, and + trailing-comment seam tests. +- Prove an authorized ESI cold miss reaches transform EOF without waiting at either a real + or publisher-authored `` sequence, then collects once before reader assembly. +- Fastly tests must prove lazy streaming order. Axum, Cloudflare, and Spin tests must prove + final-byte parity on their buffered response paths. + +### 12.5 Required verification + +Run the repository-prescribed gates: + +```bash +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +The JavaScript suite is not required unless implementation changes JavaScript or +TypeScript. Documentation formatting is required after updating the Next.js guide. + +## 13. Documentation changes + +Update `docs/guide/integrations/nextjs.md` to state: + +- HTML outside unresolved RSC groups streams as it is transformed; +- cross-script T-chunks may briefly defer an ordered group; +- `max_combined_payload_bytes` bounds both combined RSC data and output held for that group; +- over-limit, invalid, or incomplete groups are emitted unchanged to preserve hydration. + +Do not describe the integration as whole-document buffered after this change. Do not claim +that every RSC URL is rewritten when the documented safety fallback applies. + +## 14. Completion criteria + +#850 is complete when all of the following are true: + +1. `BodyCloseHoldBuffer` and every auction-coordination scan for `` literal while the auction + remains pending. +4. Enabling Next.js no longer makes every intermediate HTML processor result empty until + EOF. +5. Cross-script RSC behavior, order, length correction, and bounded unchanged fallback are + covered by tests. +6. No generated control token or placeholder reaches a client or shared cache. +7. Existing ESI assembly tests and all applicable CI gates pass.