diff --git a/crates/common/src/html_processor.rs b/crates/common/src/html_processor.rs
index f786c78..a0ac143 100644
--- a/crates/common/src/html_processor.rs
+++ b/crates/common/src/html_processor.rs
@@ -5,7 +5,6 @@ use std::cell::Cell;
use std::rc::Rc;
use lol_html::{element, html_content::ContentType, text, Settings as RewriterSettings};
-use regex::Regex;
use crate::integrations::{
AttributeRewriteOutcome, IntegrationAttributeContext, IntegrationRegistry,
@@ -22,14 +21,12 @@ pub struct HtmlProcessorConfig {
pub request_host: String,
pub request_scheme: String,
pub integrations: IntegrationRegistry,
- pub nextjs_enabled: bool,
- pub nextjs_attributes: Vec,
}
impl HtmlProcessorConfig {
/// Create from settings and request parameters
pub fn from_settings(
- settings: &Settings,
+ _settings: &Settings,
integrations: &IntegrationRegistry,
origin_host: &str,
request_host: &str,
@@ -40,8 +37,6 @@ impl HtmlProcessorConfig {
request_host: request_host.to_string(),
request_scheme: request_scheme.to_string(),
integrations: integrations.clone(),
- nextjs_enabled: settings.publisher.nextjs.enabled,
- nextjs_attributes: settings.publisher.nextjs.rewrite_attributes.clone(),
}
}
}
@@ -75,39 +70,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
fn protocol_relative_replacement(&self) -> String {
format!("//{}", self.request_host)
}
-
- fn rewrite_nextjs_values(&self, content: &str, attributes: &[String]) -> Option {
- let mut rewritten = content.to_string();
- let mut changed = false;
- let escaped_origin = regex::escape(&self.origin_host);
- for attribute in attributes {
- let escaped_attr = regex::escape(attribute);
- let pattern = format!(
- r#"(?P(?:\\*")?{attr}(?:\\*")?:\\*")(?Phttps?://|//){origin}"#,
- attr = escaped_attr,
- origin = escaped_origin
- );
- let regex = Regex::new(&pattern).expect("valid Next.js rewrite regex");
- let new_value = regex.replace_all(&rewritten, |caps: ®ex::Captures| {
- let scheme = &caps["scheme"];
- let replacement = if scheme == "//" {
- format!("//{}", self.request_host)
- } else {
- self.replacement_url()
- };
- format!("{}{}", &caps["prefix"], replacement)
- });
- if new_value != rewritten {
- changed = true;
- rewritten = new_value.into_owned();
- }
- }
- if changed {
- Some(rewritten)
- } else {
- None
- }
- }
}
let patterns = Rc::new(UrlPatterns {
@@ -116,8 +78,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
request_scheme: config.request_scheme.clone(),
});
- let nextjs_attributes = Rc::new(config.nextjs_attributes.clone());
-
let injected_tsjs = Rc::new(Cell::new(false));
let integration_registry = config.integrations.clone();
let script_rewriters = integration_registry.script_rewriters();
@@ -378,35 +338,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
}));
}
- if config.nextjs_enabled && !nextjs_attributes.is_empty() {
- element_content_handlers.push(text!("script#__NEXT_DATA__", {
- let patterns = patterns.clone();
- let attributes = nextjs_attributes.clone();
- move |text| {
- let content = text.as_str();
- if let Some(rewritten) = patterns.rewrite_nextjs_values(content, &attributes) {
- text.replace(&rewritten, ContentType::Text);
- }
- Ok(())
- }
- }));
-
- element_content_handlers.push(text!("script", {
- let patterns = patterns.clone();
- let attributes = nextjs_attributes.clone();
- move |text| {
- let content = text.as_str();
- if !content.contains("self.__next_f") {
- return Ok(());
- }
- if let Some(rewritten) = patterns.rewrite_nextjs_values(content, &attributes) {
- text.replace(&rewritten, ContentType::Text);
- }
- Ok(())
- }
- }));
- }
-
let rewriter_settings = RewriterSettings {
element_content_handlers,
..RewriterSettings::default()
@@ -433,116 +364,9 @@ mod tests {
request_host: "test.example.com".to_string(),
request_scheme: "https".to_string(),
integrations: IntegrationRegistry::default(),
- nextjs_enabled: false,
- nextjs_attributes: vec!["href".to_string(), "link".to_string(), "url".to_string()],
}
}
- fn config_from_settings(
- settings: &Settings,
- registry: &IntegrationRegistry,
- ) -> HtmlProcessorConfig {
- HtmlProcessorConfig::from_settings(
- settings,
- registry,
- "origin.example.com",
- "test.example.com",
- "https",
- )
- }
-
- #[test]
- fn test_always_injects_tsjs_script() {
- let html = r#"
-
-
- "#;
-
- let mut settings = create_test_settings();
- settings
- .integrations
- .insert_config(
- "prebid",
- &json!({
- "enabled": true,
- "server_url": "https://test-prebid.com/openrtb2/auction",
- "timeout_ms": 1000,
- "bidders": ["mocktioneer"],
- "auto_configure": false,
- "debug": false
- }),
- )
- .expect("should update prebid config");
- let registry = IntegrationRegistry::new(&settings);
- let config = config_from_settings(&settings, ®istry);
- let processor = create_html_processor(config);
- let pipeline_config = PipelineConfig {
- input_compression: Compression::None,
- output_compression: Compression::None,
- chunk_size: 8192,
- };
- let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
-
- let mut output = Vec::new();
- let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output);
- assert!(result.is_ok());
- let processed = String::from_utf8_lossy(&output);
- // When auto-configure is disabled, do not rewrite Prebid references
- assert!(processed.contains("/js/prebid.min.js"));
- assert!(processed.contains("cdn.prebid.org/prebid.js"));
- assert!(processed.contains("tsjs-unified"));
- }
-
- #[test]
- fn prebid_auto_config_removes_prebid_scripts() {
- let html = r#"
-
-
- "#;
-
- let mut settings = create_test_settings();
- settings
- .integrations
- .insert_config(
- "prebid",
- &json!({
- "enabled": true,
- "server_url": "https://test-prebid.com/openrtb2/auction",
- "timeout_ms": 1000,
- "bidders": ["mocktioneer"],
- "auto_configure": true,
- "debug": false
- }),
- )
- .expect("should update prebid config");
- let registry = IntegrationRegistry::new(&settings);
- let config = config_from_settings(&settings, ®istry);
- let processor = create_html_processor(config);
- let pipeline_config = PipelineConfig {
- input_compression: Compression::None,
- output_compression: Compression::None,
- chunk_size: 8192,
- };
- let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
-
- let mut output = Vec::new();
- let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output);
- assert!(result.is_ok());
- let processed = String::from_utf8_lossy(&output);
- assert!(
- processed.contains("tsjs-unified"),
- "Unified bundle should be injected"
- );
- assert!(
- !processed.contains("prebid.min.js"),
- "Prebid script should be removed"
- );
- assert!(
- !processed.contains("cdn.prebid.org/prebid.js"),
- "Prebid preload should be removed"
- );
- }
-
#[test]
fn integration_attribute_rewriter_can_remove_elements() {
struct RemovingLinkRewriter;
@@ -597,137 +421,6 @@ mod tests {
assert!(!processed.contains("remove-me"));
}
- #[test]
- fn test_rewrites_nextjs_script_when_enabled() {
- let html = r#"
-
- "#;
-
- let mut config = create_test_config();
- config.nextjs_enabled = true;
- config.nextjs_attributes = vec!["href".to_string(), "link".to_string(), "url".to_string()];
- let processor = create_html_processor(config);
- let pipeline_config = PipelineConfig {
- input_compression: Compression::None,
- output_compression: Compression::None,
- chunk_size: 8192,
- };
- let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
-
- let mut output = Vec::new();
- pipeline
- .process(Cursor::new(html.as_bytes()), &mut output)
- .unwrap();
- let processed = String::from_utf8_lossy(&output);
- println!("processed={processed}");
- println!("processed stream payload: {}", processed);
- println!("processed stream payload: {}", processed);
-
- assert!(
- processed.contains(r#""href":"https://test.example.com/reviews""#),
- "Should rewrite https Next.js href values"
- );
- assert!(
- processed.contains(r#""href":"https://test.example.com/sign-in""#),
- "Should rewrite http Next.js href values"
- );
- assert!(
- processed.contains(r#""fallbackHref":"http://origin.example.com/legacy""#),
- "Should leave other fields untouched"
- );
- assert!(
- processed.contains(r#""protoRelative":"//origin.example.com/assets/logo.png""#),
- "Should not rewrite non-href keys"
- );
- assert!(
- !processed.contains("\"href\":\"https://origin.example.com/reviews\""),
- "Should remove origin https href"
- );
- assert!(
- !processed.contains("\"href\":\"http://origin.example.com/sign-in\""),
- "Should remove origin http href"
- );
- }
-
- #[test]
- fn test_rewrites_nextjs_stream_payload() {
- let html = r#"
-
- "#;
-
- let mut config = create_test_config();
- config.nextjs_enabled = true;
- config.nextjs_attributes = vec!["href".to_string(), "link".to_string(), "url".to_string()];
- let processor = create_html_processor(config);
- let pipeline_config = PipelineConfig {
- input_compression: Compression::None,
- output_compression: Compression::None,
- chunk_size: 8192,
- };
- let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
-
- let mut output = Vec::new();
- pipeline
- .process(Cursor::new(html.as_bytes()), &mut output)
- .unwrap();
- let processed = String::from_utf8_lossy(&output);
- let normalized = processed.replace('\\', "");
- assert!(
- normalized.contains("\"href\":\"https://test.example.com/dashboard\""),
- "Should rewrite escaped href sequences inside streamed payloads. Content: {}",
- normalized
- );
- assert!(
- normalized.contains("\"href\":\"https://test.example.com/secondary\""),
- "Should rewrite plain href attributes inside streamed payloads"
- );
- assert!(
- normalized.contains("\"link\":\"https://test.example.com/api-test\""),
- "Should rewrite additional configured attributes like link"
- );
- assert!(
- processed.contains("\"dataHost\":\"https://origin.example.com/api\""),
- "Should leave non-href properties untouched"
- );
- }
-
- #[test]
- fn test_nextjs_rewrite_respects_flag() {
- let html = r#"
-
- "#;
-
- let config = create_test_config();
- let processor = create_html_processor(config);
- let pipeline_config = PipelineConfig {
- input_compression: Compression::None,
- output_compression: Compression::None,
- chunk_size: 8192,
- };
- let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
-
- let mut output = Vec::new();
- pipeline
- .process(Cursor::new(html.as_bytes()), &mut output)
- .unwrap();
- let processed = String::from_utf8_lossy(&output);
-
- assert!(
- processed.contains("origin.example.com"),
- "Should leave Next.js data untouched when disabled"
- );
- assert!(
- !processed.contains("test.example.com/reviews"),
- "Should not rewrite Next.js data when flag is off"
- );
- }
-
#[test]
fn test_create_html_processor_url_replacement() {
let config = create_test_config();
@@ -774,15 +467,6 @@ mod tests {
assert_eq!(config.origin_host, "origin.test-publisher.com");
assert_eq!(config.request_host, "proxy.example.com");
assert_eq!(config.request_scheme, "https");
- assert!(
- !config.nextjs_enabled,
- "Next.js rewrites should default to disabled"
- );
- assert_eq!(
- config.nextjs_attributes,
- vec!["href".to_string(), "link".to_string(), "url".to_string()],
- "Should default to rewriting href/link/url attributes"
- );
}
#[test]
diff --git a/crates/common/src/integrations/mod.rs b/crates/common/src/integrations/mod.rs
index 49071b6..6c3f883 100644
--- a/crates/common/src/integrations/mod.rs
+++ b/crates/common/src/integrations/mod.rs
@@ -2,6 +2,7 @@
use crate::settings::Settings;
+pub mod nextjs;
pub mod prebid;
mod registry;
pub mod testlight;
@@ -16,5 +17,5 @@ pub use registry::{
type IntegrationBuilder = fn(&Settings) -> Option;
pub(crate) fn builders() -> &'static [IntegrationBuilder] {
- &[prebid::register, testlight::register]
+ &[prebid::register, testlight::register, nextjs::register]
}
diff --git a/crates/common/src/integrations/nextjs.rs b/crates/common/src/integrations/nextjs.rs
new file mode 100644
index 0000000..ad33302
--- /dev/null
+++ b/crates/common/src/integrations/nextjs.rs
@@ -0,0 +1,388 @@
+use std::sync::Arc;
+
+use regex::{escape, Regex};
+use serde::{Deserialize, Serialize};
+use validator::Validate;
+
+use crate::integrations::{
+ IntegrationRegistration, IntegrationScriptContext, IntegrationScriptRewriter,
+ ScriptRewriteAction,
+};
+use crate::settings::{IntegrationConfig, Settings};
+
+const NEXTJS_INTEGRATION_ID: &str = "nextjs";
+
+#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
+pub struct NextJsIntegrationConfig {
+ #[serde(default = "default_enabled")]
+ pub enabled: bool,
+ #[serde(
+ default = "default_rewrite_attributes",
+ deserialize_with = "crate::settings::vec_from_seq_or_map"
+ )]
+ #[validate(length(min = 1))]
+ pub rewrite_attributes: Vec,
+}
+
+impl IntegrationConfig for NextJsIntegrationConfig {
+ fn is_enabled(&self) -> bool {
+ self.enabled
+ }
+}
+
+fn default_enabled() -> bool {
+ false
+}
+
+fn default_rewrite_attributes() -> Vec {
+ vec!["href".to_string(), "link".to_string(), "url".to_string()]
+}
+
+pub fn register(settings: &Settings) -> Option {
+ let config = build(settings)?;
+ let structured = Arc::new(NextJsScriptRewriter::new(
+ Arc::clone(&config),
+ NextJsRewriteMode::Structured,
+ ));
+ let streamed = Arc::new(NextJsScriptRewriter::new(
+ config,
+ NextJsRewriteMode::Streamed,
+ ));
+
+ Some(
+ IntegrationRegistration::builder(NEXTJS_INTEGRATION_ID)
+ .with_script_rewriter(structured)
+ .with_script_rewriter(streamed)
+ .build(),
+ )
+}
+
+fn build(settings: &Settings) -> Option> {
+ let config = settings
+ .integration_config::(NEXTJS_INTEGRATION_ID)
+ .ok()
+ .flatten()?;
+ Some(Arc::new(config))
+}
+
+#[derive(Clone, Copy)]
+enum NextJsRewriteMode {
+ Structured,
+ Streamed,
+}
+
+struct NextJsScriptRewriter {
+ config: Arc,
+ mode: NextJsRewriteMode,
+}
+
+impl NextJsScriptRewriter {
+ fn new(config: Arc, mode: NextJsRewriteMode) -> Self {
+ Self { config, mode }
+ }
+
+ fn rewrite_values(
+ &self,
+ content: &str,
+ ctx: &IntegrationScriptContext<'_>,
+ ) -> ScriptRewriteAction {
+ if let Some(rewritten) = rewrite_nextjs_values(
+ content,
+ ctx.origin_host,
+ ctx.request_host,
+ ctx.request_scheme,
+ &self.config.rewrite_attributes,
+ ) {
+ ScriptRewriteAction::replace(rewritten)
+ } else {
+ ScriptRewriteAction::keep()
+ }
+ }
+}
+
+impl IntegrationScriptRewriter for NextJsScriptRewriter {
+ fn integration_id(&self) -> &'static str {
+ NEXTJS_INTEGRATION_ID
+ }
+
+ fn selector(&self) -> &'static str {
+ match self.mode {
+ NextJsRewriteMode::Structured => "script#__NEXT_DATA__",
+ NextJsRewriteMode::Streamed => "script",
+ }
+ }
+
+ fn rewrite(&self, content: &str, ctx: &IntegrationScriptContext<'_>) -> ScriptRewriteAction {
+ if self.config.rewrite_attributes.is_empty() {
+ return ScriptRewriteAction::keep();
+ }
+
+ match self.mode {
+ NextJsRewriteMode::Structured => self.rewrite_values(content, ctx),
+ NextJsRewriteMode::Streamed => {
+ if !content.contains("self.__next_f") {
+ return ScriptRewriteAction::keep();
+ }
+ self.rewrite_values(content, ctx)
+ }
+ }
+ }
+}
+
+fn rewrite_nextjs_values(
+ content: &str,
+ origin_host: &str,
+ request_host: &str,
+ request_scheme: &str,
+ attributes: &[String],
+) -> Option {
+ if origin_host.is_empty() || request_host.is_empty() || attributes.is_empty() {
+ return None;
+ }
+
+ let mut rewritten = content.to_string();
+ let mut changed = false;
+ let escaped_origin = escape(origin_host);
+ let replacement_scheme = format!("{}://{}", request_scheme, request_host);
+
+ for attribute in attributes {
+ let escaped_attr = escape(attribute);
+ let pattern = format!(
+ r#"(?P(?:\\*")?{attr}(?:\\*")?:\\*")(?Phttps?://|//){origin}"#,
+ attr = escaped_attr,
+ origin = escaped_origin,
+ );
+ let regex = Regex::new(&pattern).expect("valid Next.js rewrite regex");
+ let next_value = regex.replace_all(&rewritten, |caps: ®ex::Captures<'_>| {
+ let scheme = &caps["scheme"];
+ let replacement = if scheme == "//" {
+ format!("//{}", request_host)
+ } else {
+ replacement_scheme.clone()
+ };
+ format!("{}{}", &caps["prefix"], replacement)
+ });
+ if next_value != rewritten {
+ changed = true;
+ rewritten = next_value.into_owned();
+ }
+ }
+
+ changed.then_some(rewritten)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::html_processor::{create_html_processor, HtmlProcessorConfig};
+ use crate::integrations::{IntegrationRegistry, IntegrationScriptContext, ScriptRewriteAction};
+ use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline};
+ use crate::test_support::tests::create_test_settings;
+ use serde_json::json;
+ use std::io::Cursor;
+
+ fn test_config() -> Arc {
+ Arc::new(NextJsIntegrationConfig {
+ enabled: true,
+ rewrite_attributes: vec!["href".into(), "link".into(), "url".into()],
+ })
+ }
+
+ fn ctx(selector: &'static str) -> IntegrationScriptContext<'static> {
+ IntegrationScriptContext {
+ selector,
+ request_host: "ts.example.com",
+ request_scheme: "https",
+ origin_host: "origin.example.com",
+ }
+ }
+
+ #[test]
+ fn structured_rewriter_updates_next_data_payload() {
+ let payload = r#"{"props":{"pageProps":{"primary":{"href":"https://origin.example.com/reviews"},"secondary":{"href":"http://origin.example.com/sign-in"},"fallbackHref":"http://origin.example.com/legacy","protoRelative":"//origin.example.com/assets/logo.png"}}}"#;
+ let rewriter = NextJsScriptRewriter::new(test_config(), NextJsRewriteMode::Structured);
+ let result = rewriter.rewrite(payload, &ctx("script#__NEXT_DATA__"));
+
+ match result {
+ ScriptRewriteAction::Replace(value) => {
+ assert!(value.contains(r#""href":"https://ts.example.com/reviews""#));
+ assert!(value.contains(r#""href":"https://ts.example.com/sign-in""#));
+ assert!(value.contains(r#""fallbackHref":"http://origin.example.com/legacy""#));
+ assert!(value.contains(r#""protoRelative":"//origin.example.com/assets/logo.png""#));
+ }
+ _ => panic!("Expected rewrite to update payload"),
+ }
+ }
+
+ #[test]
+ fn streamed_rewriter_only_runs_for_next_payloads() {
+ let rewriter = NextJsScriptRewriter::new(test_config(), NextJsRewriteMode::Streamed);
+
+ let noop = rewriter.rewrite("console.log('hello');", &ctx("script"));
+ assert!(matches!(noop, ScriptRewriteAction::Keep));
+
+ let payload = r#"self.__next_f.push(["chunk", "{\"href\":\"https://origin.example.com/app\"}"]);
+ "#;
+ let rewritten = rewriter.rewrite(payload, &ctx("script"));
+ match rewritten {
+ ScriptRewriteAction::Replace(value) => {
+ assert!(value.contains(r#"https://ts.example.com/app"#));
+ }
+ _ => panic!("Expected streamed payload rewrite"),
+ }
+ }
+
+ #[test]
+ fn rewrite_helper_handles_protocol_relative_urls() {
+ let content = r#"{"props":{"pageProps":{"link":"//origin.example.com/image.png"}}}"#;
+ let rewritten = rewrite_nextjs_values(
+ content,
+ "origin.example.com",
+ "ts.example.com",
+ "https",
+ &["link".into()],
+ )
+ .expect("should rewrite protocol relative link");
+
+ assert!(rewritten.contains(r#""link":"//ts.example.com/image.png""#));
+ }
+
+ fn config_from_settings(
+ settings: &Settings,
+ registry: &IntegrationRegistry,
+ ) -> HtmlProcessorConfig {
+ HtmlProcessorConfig::from_settings(
+ settings,
+ registry,
+ "origin.example.com",
+ "test.example.com",
+ "https",
+ )
+ }
+
+ #[test]
+ fn html_processor_rewrites_nextjs_script_when_enabled() {
+ let html = r#"
+
+ "#;
+
+ 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::new(&settings);
+ let config = config_from_settings(&settings, ®istry);
+ let processor = create_html_processor(config);
+ let pipeline_config = PipelineConfig {
+ input_compression: Compression::None,
+ output_compression: Compression::None,
+ chunk_size: 8192,
+ };
+ let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
+
+ let mut output = Vec::new();
+ pipeline
+ .process(Cursor::new(html.as_bytes()), &mut output)
+ .unwrap();
+ let processed = String::from_utf8_lossy(&output);
+
+ assert!(
+ processed.contains(r#""href":"https://test.example.com/reviews""#),
+ "should rewrite https Next.js href values"
+ );
+ assert!(
+ processed.contains(r#""href":"https://test.example.com/sign-in""#),
+ "should rewrite http Next.js href values"
+ );
+ assert!(
+ processed.contains(r#""fallbackHref":"http://origin.example.com/legacy""#),
+ "should leave other fields untouched"
+ );
+ assert!(
+ processed.contains(r#""protoRelative":"//origin.example.com/assets/logo.png""#),
+ "should not rewrite non-href keys"
+ );
+ assert!(
+ !processed.contains("\"href\":\"https://origin.example.com/reviews\""),
+ "should remove origin https href"
+ );
+ assert!(
+ !processed.contains("\"href\":\"http://origin.example.com/sign-in\""),
+ "should remove origin http href"
+ );
+ }
+
+ #[test]
+ fn html_processor_rewrites_nextjs_stream_payload() {
+ let html = r#"
+
+ "#;
+
+ 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::new(&settings);
+ let config = config_from_settings(&settings, ®istry);
+ let processor = create_html_processor(config);
+ let pipeline_config = PipelineConfig {
+ input_compression: Compression::None,
+ output_compression: Compression::None,
+ chunk_size: 8192,
+ };
+ let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
+
+ let mut output = Vec::new();
+ pipeline
+ .process(Cursor::new(html.as_bytes()), &mut output)
+ .unwrap();
+ let processed = String::from_utf8_lossy(&output);
+ let normalized = processed.replace('\\', "");
+ assert!(
+ normalized.contains("\"href\":\"https://test.example.com/dashboard\""),
+ "should rewrite escaped href sequences inside streamed payloads: {}",
+ normalized
+ );
+ assert!(
+ normalized.contains("\"href\":\"https://test.example.com/secondary\""),
+ "should rewrite plain href attributes inside streamed payloads"
+ );
+ assert!(
+ normalized.contains("\"link\":\"https://test.example.com/api-test\""),
+ "should rewrite additional configured attributes like link"
+ );
+ assert!(
+ processed.contains("\"dataHost\":\"https://origin.example.com/api\""),
+ "should leave non-href properties untouched"
+ );
+ }
+
+ #[test]
+ fn register_respects_enabled_flag() {
+ let settings = create_test_settings();
+ let registration = register(&settings);
+
+ assert!(
+ registration.is_none(),
+ "should skip registration when integration is disabled"
+ );
+ }
+}
diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs
index 182cf52..84c052c 100644
--- a/crates/common/src/integrations/prebid.rs
+++ b/crates/common/src/integrations/prebid.rs
@@ -670,11 +670,14 @@ fn get_request_scheme(req: &Request) -> String {
#[cfg(test)]
mod tests {
use super::*;
- use crate::integrations::AttributeRewriteAction;
+ use crate::html_processor::{create_html_processor, HtmlProcessorConfig};
+ use crate::integrations::{AttributeRewriteAction, IntegrationRegistry};
use crate::settings::Settings;
+ use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline};
use crate::test_support::tests::crate_test_settings_str;
use fastly::http::Method;
use serde_json::json;
+ use std::io::Cursor;
fn make_settings() -> Settings {
Settings::from_toml(&crate_test_settings_str()).expect("should parse settings")
@@ -691,6 +694,19 @@ mod tests {
}
}
+ fn config_from_settings(
+ settings: &Settings,
+ registry: &IntegrationRegistry,
+ ) -> HtmlProcessorConfig {
+ HtmlProcessorConfig::from_settings(
+ settings,
+ registry,
+ "origin.example.com",
+ "test.example.com",
+ "https",
+ )
+ }
+
#[test]
fn attribute_rewriter_removes_prebid_scripts() {
let integration = PrebidIntegration {
@@ -727,6 +743,106 @@ mod tests {
assert!(matches!(rewritten, AttributeRewriteAction::RemoveElement));
}
+ #[test]
+ fn html_processor_keeps_prebid_scripts_when_auto_config_disabled() {
+ let html = r#"
+
+
+ "#;
+
+ let mut settings = make_settings();
+ settings
+ .integrations
+ .insert_config(
+ "prebid",
+ &json!({
+ "enabled": true,
+ "server_url": "https://test-prebid.com/openrtb2/auction",
+ "timeout_ms": 1000,
+ "bidders": ["mocktioneer"],
+ "auto_configure": false,
+ "debug": false
+ }),
+ )
+ .expect("should update prebid config");
+ let registry = IntegrationRegistry::new(&settings);
+ let config = config_from_settings(&settings, ®istry);
+ let processor = create_html_processor(config);
+ let pipeline_config = PipelineConfig {
+ input_compression: Compression::None,
+ output_compression: Compression::None,
+ chunk_size: 8192,
+ };
+ let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
+
+ let mut output = Vec::new();
+ let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output);
+ assert!(result.is_ok());
+ let processed = String::from_utf8_lossy(&output);
+ assert!(
+ processed.contains("tsjs-unified"),
+ "Unified bundle should be injected"
+ );
+ assert!(
+ processed.contains("prebid.min.js"),
+ "Prebid script should remain when auto-config is disabled"
+ );
+ assert!(
+ processed.contains("cdn.prebid.org/prebid.js"),
+ "Prebid preload should remain when auto-config is disabled"
+ );
+ }
+
+ #[test]
+ fn html_processor_removes_prebid_scripts_when_auto_config_enabled() {
+ let html = r#"
+
+
+ "#;
+
+ let mut settings = make_settings();
+ settings
+ .integrations
+ .insert_config(
+ "prebid",
+ &json!({
+ "enabled": true,
+ "server_url": "https://test-prebid.com/openrtb2/auction",
+ "timeout_ms": 1000,
+ "bidders": ["mocktioneer"],
+ "auto_configure": true,
+ "debug": false
+ }),
+ )
+ .expect("should update prebid config");
+ let registry = IntegrationRegistry::new(&settings);
+ let config = config_from_settings(&settings, ®istry);
+ let processor = create_html_processor(config);
+ let pipeline_config = PipelineConfig {
+ input_compression: Compression::None,
+ output_compression: Compression::None,
+ chunk_size: 8192,
+ };
+ let mut pipeline = StreamingPipeline::new(pipeline_config, processor);
+
+ let mut output = Vec::new();
+ let result = pipeline.process(Cursor::new(html.as_bytes()), &mut output);
+ assert!(result.is_ok());
+ let processed = String::from_utf8_lossy(&output);
+ assert!(
+ processed.contains("tsjs-unified"),
+ "Unified bundle should be injected"
+ );
+ assert!(
+ !processed.contains("prebid.min.js"),
+ "Prebid script should be removed when auto-config is enabled"
+ );
+ assert!(
+ !processed.contains("cdn.prebid.org/prebid.js"),
+ "Prebid preload should be removed when auto-config is enabled"
+ );
+ }
+
#[test]
fn enhance_openrtb_request_adds_ids_and_regs() {
let settings = make_settings();
diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs
index 4af7a00..25149d9 100644
--- a/crates/common/src/settings.rs
+++ b/crates/common/src/settings.rs
@@ -3,10 +3,7 @@ use core::str;
use config::{Config, Environment, File, FileFormat};
use error_stack::{Report, ResultExt};
use regex::Regex;
-use serde::{
- de::{DeserializeOwned, IntoDeserializer},
- Deserialize, Deserializer, Serialize,
-};
+use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
@@ -27,9 +24,6 @@ pub struct Publisher {
/// Secret used to encrypt/decrypt proxied URLs in `/first-party/proxy`.
/// Keep this secret stable to allow existing links to decode.
pub proxy_secret: String,
- #[serde(default)]
- #[validate(nested)]
- pub nextjs: NextJs,
}
impl Publisher {
@@ -44,7 +38,6 @@ impl Publisher {
/// cookie_domain: ".example.com".to_string(),
/// origin_url: "https://origin.example.com:8080".to_string(),
/// proxy_secret: "proxy-secret".to_string(),
- /// nextjs: Default::default(),
/// };
/// assert_eq!(publisher.origin_host(), "origin.example.com:8080");
/// ```
@@ -62,30 +55,6 @@ impl Publisher {
}
}
-#[derive(Debug, Deserialize, Serialize, Validate)]
-pub struct NextJs {
- #[serde(default)]
- pub enabled: bool,
- #[serde(
- default = "default_nextjs_attributes",
- deserialize_with = "deserialize_nextjs_attributes"
- )]
- pub rewrite_attributes: Vec,
-}
-
-fn default_nextjs_attributes() -> Vec {
- vec!["href".to_string(), "link".to_string(), "url".to_string()]
-}
-
-impl Default for NextJs {
- fn default() -> Self {
- Self {
- enabled: false,
- rewrite_attributes: default_nextjs_attributes(),
- }
- }
-}
-
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct IntegrationSettings {
#[serde(flatten)]
@@ -187,18 +156,6 @@ impl DerefMut for IntegrationSettings {
}
}
-fn deserialize_nextjs_attributes<'de, D>(deserializer: D) -> Result, D::Error>
-where
- D: Deserializer<'de>,
-{
- let value = Option::::deserialize(deserializer)?;
- match value {
- Some(json) => vec_from_seq_or_map(json.into_deserializer())
- .map_err(::custom),
- None => Ok(default_nextjs_attributes()),
- }
-}
-
#[allow(unused)]
#[derive(Debug, Default, Deserialize, Serialize, Validate)]
pub struct Synthetic {
@@ -457,8 +414,9 @@ where
mod tests {
use super::*;
use regex::Regex;
+ use serde_json::json;
- use crate::integrations::prebid::PrebidIntegrationConfig;
+ use crate::integrations::{nextjs::NextJsIntegrationConfig, prebid::PrebidIntegrationConfig};
use crate::test_support::tests::{crate_test_settings_str, create_test_settings};
#[test]
@@ -479,12 +437,20 @@ mod tests {
.expect("Prebid config should load from default settings");
assert!(!prebid_cfg.server_url.is_empty());
assert!(
- !settings.publisher.nextjs.enabled,
- "Next.js URL rewriting should default to disabled"
+ settings
+ .integration_config::("nextjs")
+ .expect("Next.js config query should succeed")
+ .is_none(),
+ "Next.js integration should be disabled by default"
);
+ let raw_nextjs = settings
+ .integrations
+ .get("nextjs")
+ .expect("embedded config should include nextjs block");
+ assert_eq!(raw_nextjs["enabled"], json!(false));
assert_eq!(
- settings.publisher.nextjs.rewrite_attributes,
- vec!["href".to_string(), "link".to_string(), "url".to_string()],
+ raw_nextjs["rewrite_attributes"],
+ json!(["href", "link", "url"]),
"Next.js rewrite attributes should default to href/link/url"
);
@@ -511,12 +477,20 @@ mod tests {
"https://test-prebid.com/openrtb2/auction"
);
assert!(
- !settings.publisher.nextjs.enabled,
- "Next.js URL rewriting should default to disabled"
+ settings
+ .integration_config::("nextjs")
+ .expect("Next.js config query should succeed")
+ .is_none(),
+ "Next.js integration should default to disabled"
);
+ let raw_nextjs = settings
+ .integrations
+ .get("nextjs")
+ .expect("test settings should include nextjs block");
+ assert_eq!(raw_nextjs["enabled"], json!(false));
assert_eq!(
- settings.publisher.nextjs.rewrite_attributes,
- vec!["href".to_string(), "link".to_string(), "url".to_string()],
+ raw_nextjs["rewrite_attributes"],
+ json!(["href", "link", "url"]),
"Next.js rewrite attributes should default to href/link/url"
);
assert_eq!(settings.publisher.domain, "test-publisher.com");
@@ -784,7 +758,6 @@ mod tests {
cookie_domain: ".example.com".to_string(),
origin_url: "https://origin.example.com:8080".to_string(),
proxy_secret: "test-secret".to_string(),
- nextjs: NextJs::default(),
};
assert_eq!(publisher.origin_host(), "origin.example.com:8080");
@@ -794,7 +767,6 @@ mod tests {
cookie_domain: ".example.com".to_string(),
origin_url: "https://origin.example.com".to_string(),
proxy_secret: "test-secret".to_string(),
- nextjs: NextJs::default(),
};
assert_eq!(publisher.origin_host(), "origin.example.com");
@@ -804,7 +776,6 @@ mod tests {
cookie_domain: ".example.com".to_string(),
origin_url: "http://localhost:9090".to_string(),
proxy_secret: "test-secret".to_string(),
- nextjs: NextJs::default(),
};
assert_eq!(publisher.origin_host(), "localhost:9090");
@@ -814,7 +785,6 @@ mod tests {
cookie_domain: ".example.com".to_string(),
origin_url: "localhost:9090".to_string(),
proxy_secret: "test-secret".to_string(),
- nextjs: NextJs::default(),
};
assert_eq!(publisher.origin_host(), "localhost:9090");
@@ -824,7 +794,6 @@ mod tests {
cookie_domain: ".example.com".to_string(),
origin_url: "http://192.168.1.1:8080".to_string(),
proxy_secret: "test-secret".to_string(),
- nextjs: NextJs::default(),
};
assert_eq!(publisher.origin_host(), "192.168.1.1:8080");
@@ -834,7 +803,6 @@ mod tests {
cookie_domain: ".example.com".to_string(),
origin_url: "http://[::1]:8080".to_string(),
proxy_secret: "test-secret".to_string(),
- nextjs: NextJs::default(),
};
assert_eq!(publisher.origin_host(), "[::1]:8080");
}
diff --git a/crates/common/src/test_support.rs b/crates/common/src/test_support.rs
index d3ff379..3b2f85c 100644
--- a/crates/common/src/test_support.rs
+++ b/crates/common/src/test_support.rs
@@ -20,6 +20,10 @@ pub mod tests {
enabled = true
server_url = "https://test-prebid.com/openrtb2/auction"
+ [integrations.nextjs]
+ enabled = false
+ rewrite_attributes = ["href", "link", "url"]
+
[synthetic]
counter_store = "test-counter-store"
opid_store = "test-opid-store"
diff --git a/trusted-server.toml b/trusted-server.toml
index b5ea36b..864dbc8 100644
--- a/trusted-server.toml
+++ b/trusted-server.toml
@@ -9,11 +9,6 @@ cookie_domain = ".test-publisher.com"
origin_url = "https://origin.test-publisher.com"
proxy_secret = "change-me-proxy-secret"
-[publisher.nextjs]
-enabled = false
-rewrite_attributes = ["href", "link", "url"]
-
-
[synthetic]
counter_store = "counter_store"
opid_store = "opid_store"
@@ -46,6 +41,10 @@ bidders = ["kargo", "rubicon", "appnexus", "openx"]
auto_configure = false
debug = false
+[integrations.nextjs]
+enabled = false
+rewrite_attributes = ["href", "link", "url"]
+
[integrations.testlight]
endpoint = "https://testlight.example/openrtb2/auction"
timeout_ms = 1200