From 9cb6f420d9f7fd4dbd120d27a95b92604a217625 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 17 Nov 2025 17:38:13 -0600 Subject: [PATCH 1/6] use environment variable for selective bundling --- crates/common/src/creative.rs | 15 +- crates/common/src/html_processor.rs | 308 ++++------------------------ crates/common/src/tsjs.rs | 134 ++---------- crates/js/build.rs | 99 ++++----- crates/js/lib/.gitignore | 2 + crates/js/lib/README.md | 154 ++++++++++++++ crates/js/lib/package-lock.json | 9 - crates/js/lib/package.json | 6 +- crates/js/lib/scripts/watch-all.mjs | 33 --- crates/js/lib/src/index.ts | 48 +++++ crates/js/lib/vite.config.ts | 152 +++++++------- crates/js/src/bundle.rs | 29 ++- permutive-callstack.md | 61 ++++++ 13 files changed, 464 insertions(+), 586 deletions(-) create mode 100644 crates/js/lib/.gitignore create mode 100644 crates/js/lib/README.md delete mode 100755 crates/js/lib/scripts/watch-all.mjs create mode 100644 crates/js/lib/src/index.ts create mode 100644 permutive-callstack.md diff --git a/crates/common/src/creative.rs b/crates/common/src/creative.rs index c6c57f1..c74172a 100644 --- a/crates/common/src/creative.rs +++ b/crates/common/src/creative.rs @@ -300,12 +300,13 @@ pub fn rewrite_creative_html(markup: &str, settings: &Settings) -> String { let mut rewriter = HtmlRewriter::new( HtmlSettings { element_content_handlers: vec![ - // Inject tsjs-creative at the top of body once + // Inject unified tsjs bundle at the top of body once + // This includes core + creative guards + ext + permutive modules element!("body", { let injected = injected_ts_creative.clone(); move |el| { if !injected.get() { - let script_tag = tsjs::creative_script_tag(); + let script_tag = tsjs::unified_script_tag(); el.prepend(&script_tag, ContentType::Html); injected.set(true); } @@ -490,20 +491,20 @@ mod tests { let html = r#"

hello

"#; let out = rewrite_creative_html(html, &settings); assert!( - out.contains("/static/tsjs=tsjs-creative.min.js"), - "expected tsjs-creative injection: {}", + out.contains("/static/tsjs=tsjs-unified.min.js"), + "expected unified tsjs injection: {}", out ); // Inject only once - assert_eq!(out.matches("/static/tsjs=tsjs-creative.min.js").count(), 1); + assert_eq!(out.matches("/static/tsjs=tsjs-unified.min.js").count(), 1); } #[test] - fn injects_tsjs_creative_once_with_multiple_bodies() { + fn injects_tsjs_unified_once_with_multiple_bodies() { let settings = crate::test_support::tests::create_test_settings(); let html = r#"onetwo"#; let out = rewrite_creative_html(html, &settings); - assert_eq!(out.matches("/static/tsjs=tsjs-creative.min.js").count(), 1); + assert_eq!(out.matches("/static/tsjs=tsjs-unified.min.js").count(), 1); } #[test] diff --git a/crates/common/src/html_processor.rs b/crates/common/src/html_processor.rs index b40787f..dc364f7 100644 --- a/crates/common/src/html_processor.rs +++ b/crates/common/src/html_processor.rs @@ -2,15 +2,11 @@ //! //! This module provides a StreamProcessor implementation for HTML content. use std::cell::Cell; -use std::collections::BTreeSet; use std::rc::Rc; use lol_html::{element, html_content::ContentType, text, Settings as RewriterSettings}; use regex::Regex; -use crate::integrations::{ - IntegrationAttributeContext, IntegrationRegistry, IntegrationScriptContext, -}; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; @@ -22,36 +18,25 @@ pub struct HtmlProcessorConfig { pub request_host: String, pub request_scheme: String, pub enable_prebid: bool, - pub integrations: IntegrationRegistry, pub nextjs_enabled: bool, pub nextjs_attributes: Vec, - pub integration_assets: Vec, } impl HtmlProcessorConfig { /// Create from settings and request parameters pub fn from_settings( settings: &Settings, - integrations: &IntegrationRegistry, origin_host: &str, request_host: &str, request_scheme: &str, ) -> Self { - let asset_set: BTreeSet = integrations - .registered_integrations() - .into_iter() - .flat_map(|meta| meta.assets) - .collect(); - Self { origin_host: origin_host.to_string(), request_host: request_host.to_string(), request_scheme: request_scheme.to_string(), enable_prebid: settings.prebid.auto_configure, - integrations: integrations.clone(), nextjs_enabled: settings.publisher.nextjs.enabled, nextjs_attributes: settings.publisher.nextjs.rewrite_attributes.clone(), - integration_assets: asset_set.into_iter().collect(), } } } @@ -129,10 +114,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let nextjs_attributes = Rc::new(config.nextjs_attributes.clone()); let injected_tsjs = Rc::new(Cell::new(false)); - let integration_assets = Rc::new(config.integration_assets.clone()); - let injected_assets = Rc::new(Cell::new(false)); - let integration_registry = config.integrations.clone(); - let script_rewriters = integration_registry.script_rewriters(); fn is_prebid_script_url(url: &str) -> bool { let lower = url.to_ascii_lowercase(); @@ -145,142 +126,82 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso } let mut element_content_handlers = vec![ + // Inject unified tsjs bundle once at the start of element!("head", { let injected_tsjs = injected_tsjs.clone(); - let integration_assets = integration_assets.clone(); - let injected_assets = injected_assets.clone(); move |el| { if !injected_tsjs.get() { - let loader = tsjs::core_script_tag(); + let loader = tsjs::unified_script_tag(); el.prepend(&loader, ContentType::Html); injected_tsjs.set(true); } - if !integration_assets.is_empty() && !injected_assets.get() { - for asset in integration_assets.iter() { - let attrs = format!("async data-tsjs-integration=\"{}\"", asset); - let tag = tsjs::integration_script_tag(asset, &attrs); - el.append(&tag, ContentType::Html); - } - injected_assets.set(true); - } Ok(()) } }), + // Replace URLs in href attributes element!("[href]", { let patterns = patterns.clone(); let rewrite_prebid = config.enable_prebid; - let integrations = integration_registry.clone(); move |el| { - if let Some(mut href) = el.get_attribute("href") { - let original_href = href.clone(); + if let Some(href) = el.get_attribute("href") { + // If Prebid auto-config is enabled and this looks like a Prebid script href, + // remove it since the unified bundle already includes Prebid integration if rewrite_prebid && is_prebid_script_url(&href) { - href = tsjs::ext_script_src(); + el.remove(); } else { let new_href = href .replace(&patterns.https_origin(), &patterns.replacement_url()) .replace(&patterns.http_origin(), &patterns.replacement_url()); if new_href != href { - href = new_href; + el.set_attribute("href", &new_href)?; } } - - if let Some(integration_href) = integrations.rewrite_attribute( - "href", - &href, - &IntegrationAttributeContext { - attribute_name: "href", - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - }, - ) { - href = integration_href; - } - - if href != original_href { - el.set_attribute("href", &href)?; - } } Ok(()) } }), + // Replace URLs in src attributes element!("[src]", { let patterns = patterns.clone(); let rewrite_prebid = config.enable_prebid; - let integrations = integration_registry.clone(); move |el| { - if let Some(mut src) = el.get_attribute("src") { - let original_src = src.clone(); + if let Some(src) = el.get_attribute("src") { + // If Prebid auto-config is enabled and this looks like a Prebid script, + // remove it since the unified bundle already includes Prebid integration if rewrite_prebid && is_prebid_script_url(&src) { - src = tsjs::ext_script_src(); + el.remove(); } else { let new_src = src .replace(&patterns.https_origin(), &patterns.replacement_url()) .replace(&patterns.http_origin(), &patterns.replacement_url()); if new_src != src { - src = new_src; + el.set_attribute("src", &new_src)?; } } - - if let Some(integration_src) = integrations.rewrite_attribute( - "src", - &src, - &IntegrationAttributeContext { - attribute_name: "src", - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - }, - ) { - src = integration_src; - } - - if src != original_src { - el.set_attribute("src", &src)?; - } } Ok(()) } }), + // Replace URLs in action attributes element!("[action]", { let patterns = patterns.clone(); - let integrations = integration_registry.clone(); move |el| { - if let Some(mut action) = el.get_attribute("action") { - let original_action = action.clone(); + if let Some(action) = el.get_attribute("action") { let new_action = action .replace(&patterns.https_origin(), &patterns.replacement_url()) .replace(&patterns.http_origin(), &patterns.replacement_url()); if new_action != action { - action = new_action; - } - - if let Some(integration_action) = integrations.rewrite_attribute( - "action", - &action, - &IntegrationAttributeContext { - attribute_name: "action", - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - }, - ) { - action = integration_action; - } - - if action != original_action { - el.set_attribute("action", &action)?; + el.set_attribute("action", &new_action)?; } } Ok(()) } }), + // Replace URLs in srcset attributes (for responsive images) element!("[srcset]", { let patterns = patterns.clone(); - let integrations = integration_registry.clone(); move |el| { - if let Some(mut srcset) = el.get_attribute("srcset") { - let original_srcset = srcset.clone(); + if let Some(srcset) = el.get_attribute("srcset") { let new_srcset = srcset .replace(&patterns.https_origin(), &patterns.replacement_url()) .replace(&patterns.http_origin(), &patterns.replacement_url()) @@ -289,36 +210,19 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso &patterns.protocol_relative_replacement(), ) .replace(&patterns.origin_host, &patterns.request_host); - if new_srcset != srcset { - srcset = new_srcset; - } - if let Some(integration_srcset) = integrations.rewrite_attribute( - "srcset", - &srcset, - &IntegrationAttributeContext { - attribute_name: "srcset", - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - }, - ) { - srcset = integration_srcset; - } - - if srcset != original_srcset { - el.set_attribute("srcset", &srcset)?; + if new_srcset != srcset { + el.set_attribute("srcset", &new_srcset)?; } } Ok(()) } }), + // Replace URLs in imagesrcset attributes (for link preload) element!("[imagesrcset]", { let patterns = patterns.clone(); - let integrations = integration_registry.clone(); move |el| { - if let Some(mut imagesrcset) = el.get_attribute("imagesrcset") { - let original_imagesrcset = imagesrcset.clone(); + if let Some(imagesrcset) = el.get_attribute("imagesrcset") { let new_imagesrcset = imagesrcset .replace(&patterns.https_origin(), &patterns.replacement_url()) .replace(&patterns.http_origin(), &patterns.replacement_url()) @@ -327,24 +231,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso &patterns.protocol_relative_replacement(), ); if new_imagesrcset != imagesrcset { - imagesrcset = new_imagesrcset; - } - - if let Some(integration_imagesrcset) = integrations.rewrite_attribute( - "imagesrcset", - &imagesrcset, - &IntegrationAttributeContext { - attribute_name: "imagesrcset", - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - }, - ) { - imagesrcset = integration_imagesrcset; - } - - if imagesrcset != original_imagesrcset { - el.set_attribute("imagesrcset", &imagesrcset)?; + el.set_attribute("imagesrcset", &new_imagesrcset)?; } } Ok(()) @@ -352,28 +239,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso }), ]; - for script_rewriter in script_rewriters { - let selector = script_rewriter.selector(); - let rewriter = script_rewriter.clone(); - let patterns = patterns.clone(); - element_content_handlers.push(text!(selector, { - let rewriter = rewriter.clone(); - let patterns = patterns.clone(); - move |text| { - let ctx = IntegrationScriptContext { - selector, - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - }; - if let Some(rewritten) = rewriter.rewrite(text.as_str(), &ctx) { - text.replace(&rewritten, ContentType::Text); - } - Ok(()) - } - })); - } - if config.nextjs_enabled && !nextjs_attributes.is_empty() { element_content_handlers.push(text!("script#__NEXT_DATA__", { let patterns = patterns.clone(); @@ -405,28 +270,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let rewriter_settings = RewriterSettings { element_content_handlers, - - // TODO: Consider adding text content replacement if needed with settings - // // Replace URLs in text content - // document_content_handlers: vec![lol_html::doc_text!({ - // move |text| { - // let content = text.as_str(); - - // // Apply URL replacements - // let mut new_content = content.to_string(); - // for replacement in replacer.replacements.iter() { - // if new_content.contains(&replacement.find) { - // new_content = new_content.replace(&replacement.find, &replacement.replace_with); - // } - // } - - // if new_content != content { - // text.replace(&new_content, lol_html::html_content::ContentType::Text); - // } - - // Ok(()) - // } - // })], ..RewriterSettings::default() }; @@ -437,46 +280,28 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso mod tests { use super::*; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; - use crate::tsjs; use std::io::Cursor; - const MOCK_TESTLIGHT_SRC: &str = "https://mock.testassets/testlight.js"; - - struct MockBundleGuard; - - fn mock_testlight_bundle() -> MockBundleGuard { - tsjs::mock_integration_bundle("testlight", MOCK_TESTLIGHT_SRC); - MockBundleGuard - } - - impl Drop for MockBundleGuard { - fn drop(&mut self) { - tsjs::clear_mock_integration_bundles(); - } - } - fn create_test_config() -> HtmlProcessorConfig { HtmlProcessorConfig { origin_host: "origin.example.com".to_string(), request_host: "test.example.com".to_string(), request_scheme: "https".to_string(), enable_prebid: false, - integrations: IntegrationRegistry::default(), nextjs_enabled: false, nextjs_attributes: vec!["href".to_string(), "link".to_string(), "url".to_string()], - integration_assets: Vec::new(), } } #[test] - fn test_injects_tsjs_script_and_rewrites_prebid_refs() { + fn test_injects_unified_bundle_and_removes_prebid_refs() { let html = r#" "#; let mut config = create_test_config(); - config.enable_prebid = true; // enable rewriting of Prebid URLs + config.enable_prebid = true; // enable removal of Prebid URLs let processor = create_html_processor(config); let pipeline_config = PipelineConfig { input_compression: Compression::None, @@ -489,19 +314,21 @@ mod tests { 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("/static/tsjs=tsjs-core.min.js")); - // Prebid references are rewritten to our extension when auto-configure is on - assert!(processed.contains("/static/tsjs=tsjs-ext.min.js")); + // Should inject unified bundle (includes core + ext + creative + permutive) + assert!(processed.contains("/static/tsjs=tsjs-unified.min.js")); + // Prebid script references should be removed when auto-configure is on + assert!(!processed.contains("prebid.min.js")); + assert!(!processed.contains("cdn.prebid.org/prebid.js")); } #[test] - fn test_injects_tsjs_script_and_rewrites_prebid_with_query_string() { + fn test_injects_unified_bundle_and_removes_prebid_with_query_string() { let html = r#" "#; let mut config = create_test_config(); - config.enable_prebid = true; // enable rewriting of Prebid URLs + config.enable_prebid = true; // enable removal of Prebid URLs let processor = create_html_processor(config); let pipeline_config = PipelineConfig { input_compression: Compression::None, @@ -514,19 +341,21 @@ mod tests { 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("/static/tsjs=tsjs-core.min.js")); - assert!(processed.contains("/static/tsjs=tsjs-ext.min.js")); + // Should inject unified bundle + assert!(processed.contains("/static/tsjs=tsjs-unified.min.js")); + // Prebid script should be removed + assert!(!processed.contains("prebidjs.min.js")); } #[test] - fn test_always_injects_tsjs_script() { + fn test_always_injects_unified_bundle() { let html = r#" "#; let mut config = create_test_config(); - config.enable_prebid = false; // No longer affects tsjs injection + config.enable_prebid = false; // When disabled, don't remove Prebid scripts let processor = create_html_processor(config); let pipeline_config = PipelineConfig { input_compression: Compression::None, @@ -539,10 +368,11 @@ mod tests { 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 + // When auto-configure is disabled, do not remove Prebid references assert!(processed.contains("/js/prebid.min.js")); assert!(processed.contains("cdn.prebid.org/prebid.js")); - assert!(processed.contains("/static/tsjs=tsjs-core.min.js")); + // But still inject unified bundle + assert!(processed.contains("/static/tsjs=tsjs-unified.min.js")); } #[test] @@ -712,10 +542,8 @@ mod tests { use crate::test_support::tests::create_test_settings; let settings = create_test_settings(); - let registry = IntegrationRegistry::new(&settings); let config = HtmlProcessorConfig::from_settings( &settings, - ®istry, "origin.test-publisher.com", "proxy.example.com", "https", @@ -808,58 +636,6 @@ mod tests { ); } - #[test] - fn test_integration_registry_rewrites_integration_scripts() { - use serde_json::json; - - let html = r#" - - "#; - - let _bundle_guard = mock_testlight_bundle(); - let mut settings = Settings::default(); - let shim_src = tsjs::integration_script_src("testlight"); - settings - .integrations - .insert_config( - "testlight", - &json!({ - "enabled": true, - "endpoint": "https://example.com/openrtb2/auction", - "rewrite_scripts": true, - "shim_src": shim_src, - }), - ) - .expect("should insert testlight config"); - - let registry = IntegrationRegistry::new(&settings); - let mut config = create_test_config(); - config.integrations = registry; - - 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); - let expected_src = tsjs::integration_script_src("testlight"); - assert!( - processed.contains(&expected_src), - "Integration shim should replace integration script reference" - ); - assert!( - !processed.contains("cdn.testlight.com"), - "Original integration URL should be removed" - ); - } - #[test] fn test_real_publisher_html_with_gzip() { use flate2::read::GzDecoder; diff --git a/crates/common/src/tsjs.rs b/crates/common/src/tsjs.rs index ffb1e68..4fd0d70 100644 --- a/crates/common/src/tsjs.rs +++ b/crates/common/src/tsjs.rs @@ -1,26 +1,14 @@ -use trusted_server_js::{bundle_for_filename, bundle_hash}; +use trusted_server_js::{bundle_hash, TsjsBundle}; -#[cfg(test)] -use once_cell::sync::Lazy; -#[cfg(test)] -use std::collections::HashMap; -#[cfg(test)] -use std::sync::RwLock; - -#[cfg(test)] -static MOCK_INTEGRATION_BUNDLES: Lazy>> = - Lazy::new(|| RwLock::new(HashMap::new())); - -fn script_src_internal(filename: &str) -> String { - script_src(filename).unwrap_or_else(|| { - panic!( - "tsjs: bundle {} not found. Ensure npm build produced this bundle.", - filename - ) - }) +fn script_src_for(bundle: TsjsBundle) -> String { + format!( + "/static/tsjs={}?v={}", + bundle.minified_filename(), + bundle_hash(bundle) + ) } -fn script_tag_internal(filename: &str, attrs: &str) -> String { +fn script_tag_for(bundle: TsjsBundle, attrs: &str) -> String { let attr_segment = if attrs.is_empty() { String::new() } else { @@ -28,107 +16,19 @@ fn script_tag_internal(filename: &str, attrs: &str) -> String { }; format!( "", - script_src_internal(filename), + script_src_for(bundle), attr_segment ) } -/// Returns `/static/tsjs=?v=` if the bundle is available. -pub fn script_src(filename: &str) -> Option { - let trimmed = filename.trim(); - bundle_for_filename(trimmed)?; - let hash = bundle_hash(trimmed)?; - let minified = trimmed - .strip_suffix(".js") - .map(|stem| format!("{stem}.min.js")) - .unwrap_or_else(|| format!("{trimmed}.min.js")); - Some(format!("/static/tsjs={}?v={}", minified, hash)) -} - -/// Returns a `", src) - } else { - format!("", src, attrs) - } - }) -} - -/// `", src) - } else { - format!("", src, attrs) - } -} - -#[cfg(test)] -pub fn mock_integration_bundle(name: &str, src: impl Into) { - MOCK_INTEGRATION_BUNDLES - .write() - .expect("mock bundle lock should not be poisoned") - .insert(name.to_string(), src.into()); +/// `/static` URL for the unified bundle with cache-busting hash. +/// This includes core + all enabled modules (ext, creative, permutive). +pub fn unified_script_src() -> String { + script_src_for(TsjsBundle::Unified) } -#[cfg(test)] -pub fn clear_mock_integration_bundles() { - MOCK_INTEGRATION_BUNDLES - .write() - .expect("mock bundle lock should not be poisoned") - .clear(); +/// ` - // optionally load Prebid shim when pbjs is present - // - -``` - -## Auto‑Rewrite (Server) - -- When auto-configure is enabled, the HTML processor injects the core loader and rewrites any Prebid script URLs to `/static/tsjs=tsjs-ext.min.js`. The extension aliases `window.pbjs` to `window.tsjs` and flushes `pbjs.que`. -- Proxied creative HTML injects the creative helper once at the top of ``: `/static/tsjs=tsjs-creative.min.js`. The helper monitors anchors for script-driven rewrites and rebuilds first-party click URLs whenever creatives mutate them. - -## First-Party Proxy Flows - -The Rust services (`trusted-server-common`) expose several proxy entry points that work together to keep all ad traffic on the publisher’s domain while propagating the synthetic identifier generated for the user. - -### Publisher Origin Proxy - -- Endpoint: `handle_publisher_request` (`crates/common/src/publisher.rs`). -- Retrieves or generates the trusted synthetic identifier before Fastly consumes the request body. -- Always stamps the proxied response with `X-Synthetic-Fresh` and `x-psid-ts` headers and, when the browser does not already present one, sets the `synthetic_id=` cookie (Secure + SameSite=Lax) bound to the configured publisher domain. -- Result: downstream assets fetched through the same first-party origin automatically include the synthetic ID header/cookie so subsequent proxy layers can read it. - -### Creative Asset Proxy - -- Endpoint: `handle_first_party_proxy` (`crates/common/src/proxy.rs`). -- Accepts the signed `/first-party/proxy?tsurl=...` URLs injected by the HTML rewriter and streams the creative from the third-party origin. -- Extracts the synthetic ID from the inbound cookie or header and forwards it to the creative origin by appending `synthetic_id=` to the rewritten target URL (while preserving existing query parameters). -- Follows HTTP redirects (301/302/303/307/308) up to four hops, re-validating each `Location`, switching to `GET` after 303 responses, and propagating the synthetic ID on every hop. -- Ensures the response body is rewritten when it is HTML/CSS/JS so all nested asset requests loop back through the same first-party proxy. - -### Click-Through Proxy - -- Endpoint: `handle_first_party_click` (`crates/common/src/proxy.rs`). -- Validates the signed `/first-party/click` URL generated for anchors inside proxied creatives. -- On success, issues an HTTP 302 to the reconstructed destination and appends `synthetic_id=` if the user presented one, letting downstream measurement end points correlate the click with the original synthetic identifier. -- Ensures click responses are never cached (`Cache-Control: no-store, private`). - -Together these layers guarantee that the synthetic identifier generated on the publisher response is preserved throughout page loads, asset fetches, and click-throughs without exposing the third-party origins directly to the browser. - -## Notes - -- By default, the build fails if `tsjs-core.js` cannot be produced. To change behavior: - - `TSJS_SKIP_BUILD=1`: skip running npm; requires `dist/tsjs-core.js` to exist so it can be copied to `OUT_DIR`. - - `TSJS_ALLOW_FALLBACK=1`: allow using a checked‑in `dist/tsjs-core.js` if the npm build didn’t produce an output. - - `TSJS_TEST=1`: run `npm test` during the build. diff --git a/crates/js/lib/README.md b/crates/js/lib/README.md deleted file mode 100644 index de64ffa..0000000 --- a/crates/js/lib/README.md +++ /dev/null @@ -1,154 +0,0 @@ -# tsjs - Trusted Server JavaScript Library - -Unified JavaScript library with queue-based API and modular architecture for ad serving, creative protection, and third-party integrations. - -## Architecture - -The library uses a **conditional compilation** system that allows building a single unified bundle with only the modules you need, instead of loading multiple separate bundles. - -### Available Modules - -- **core** - Core API (queue, config, ad units, rendering) - *Always included* -- **ext** - Extensions (Prebid.js integration) -- **creative** - Creative runtime guards (click protection, render guards) -- **permutive** - Permutive SDK proxy for first-party data - -## Building - -Build a single unified bundle with the modules you need: - -```bash -# Default: full featured bundle (all modules) - ~24 KB -npm run build - -# Minimal bundle (core only) - ~9.4 KB -npm run build:minimal - -# Custom combination -TSJS_MODULES=core,ext,creative npm run build:custom -``` - -### Bundle Size Options - -| Configuration | Size | Gzipped | Use Case | -|--------------|------|---------|----------| -| `core` only | 9.4 KB | 4.0 KB | Minimal ad serving | -| `core,ext` | 10.8 KB | 4.4 KB | Ad serving + Prebid | -| `core,creative` | 21.8 KB | 7.7 KB | Ad serving + protection | -| `core,ext,creative,permutive` | 24.0 KB | 8.4 KB | Full featured (default) | - -## Development - -```bash -# Watch mode (rebuilds on changes) -npm run dev - -# Run tests -npm test - -# Lint -npm run lint -``` - -## How It Works - -### Auto-Discovery Plugin - -The Vite plugin (`tsjs-module-discovery`) automatically: - -1. Scans `src/` for directories containing `index.ts` -2. Filters based on `TSJS_MODULES` environment variable -3. Generates `src/generated-modules.ts` with conditional imports -4. Only imports specified modules → Rollup tree-shakes everything else - -### Generated Code - -When you build with `TSJS_MODULES=core,creative`, the plugin generates: - -```typescript -// src/generated-modules.ts (auto-generated) -import * as core from './core/index'; -import * as creative from './creative/index'; - -export const modules = { - core, - creative, -}; -``` - -Modules not listed (like `ext` and `permutive`) are never imported, so they're completely removed from the bundle by Rollup's tree-shaking. - -### Entry Point - -The unified entry point (`src/index.ts`) imports the generated modules and initializes them: - -```typescript -import { modules } from './generated-modules'; - -// Core module self-initializes on import -// Other modules are logged and made available -``` - -## Adding New Modules - -To add a new module: - -1. Create a new directory in `src/` (e.g., `src/mymodule/`) -2. Add an `index.ts` file that exports your module's API -3. The plugin will automatically discover it -4. Include it in builds: `TSJS_MODULES=core,mymodule` - -No other configuration needed! - -## Environment Variables - -- `TSJS_UNIFIED` - Set to `true` to use unified bundle mode -- `TSJS_MODULES` - Comma-separated list of modules to include (e.g., `core,ext,creative`) -- `TSJS_BUNDLE` - (Legacy) Specifies which individual bundle to build - -## Testing - -```bash -# Run all tests -npm test - -# Watch mode -npm run test:watch -``` - -## Code Quality - -```bash -# Format check -npm run format - -# Format fix -npm run format:write - -# Lint -npm run lint - -# Lint fix -npm run lint:fix -``` - -## Project Structure - -``` -src/ - core/ - Core API (queue, config, rendering) - creative/ - Creative protection (click guards, render guards) - ext/ - Extensions (Prebid.js integration) - permutive/ - Permutive SDK proxy - shared/ - Shared utilities (async helpers, scheduler) - index.ts - Unified bundle entry point - generated-modules.ts - Auto-generated (git-ignored) -``` - -## Benefits - -- **Single bundle** - One JavaScript file instead of multiple -- **Conditional compilation** - Only includes modules you need -- **Smaller size** - Shared code included once, tree-shaking removes unused code -- **Better caching** - Single file to cache -- **Flexible** - Build minimal (9.4 KB) to full featured (24 KB) diff --git a/crates/js/lib/src/index.ts b/crates/js/lib/src/index.ts index f62e30d..7cd2a5e 100644 --- a/crates/js/lib/src/index.ts +++ b/crates/js/lib/src/index.ts @@ -35,7 +35,7 @@ for (const [moduleName, moduleExports] of Object.entries(modules)) { // Log that the module is available log.debug(`tsjs: module '${moduleName}' loaded`); - // Some modules like 'ext' and 'permutive' are self-initializing (they run on import) + // Some modules like 'ext' are self-initializing (they run on import) // Some modules like 'creative' export an API object // We don't need to do anything special here - just importing them is enough } From 2a63452677ab5858d50098867131e6a70aefa921 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 19 Nov 2025 18:03:01 -0600 Subject: [PATCH 6/6] remove the macros file --- crates/js/src/bundle.rs | 55 ++++++++++++++++++++++++++++++--- crates/js/src/lib.rs | 1 - crates/js/src/macros.rs | 68 ----------------------------------------- 3 files changed, 51 insertions(+), 73 deletions(-) delete mode 100644 crates/js/src/macros.rs diff --git a/crates/js/src/bundle.rs b/crates/js/src/bundle.rs index 7f0d09a..8b7e0ff 100644 --- a/crates/js/src/bundle.rs +++ b/crates/js/src/bundle.rs @@ -1,4 +1,3 @@ -use super::macros::{count_variants, define_tsjs_bundles}; use hex::encode; use sha2::{Digest, Sha256}; @@ -14,9 +13,57 @@ impl TsjsMeta { } } -define_tsjs_bundles!( - Unified => "tsjs-unified.js", -); +const TSJS_BUNDLE_COUNT: usize = 1; + +#[repr(usize)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum TsjsBundle { + Unified, +} + +const METAS: [TsjsMeta; TSJS_BUNDLE_COUNT] = [ + TsjsMeta::new("tsjs-unified.js", include_str!(concat!(env!("OUT_DIR"), "/tsjs-unified.js"))), +]; + +const ALL_BUNDLES: [TsjsBundle; TSJS_BUNDLE_COUNT] = [ + TsjsBundle::Unified, +]; + +impl TsjsBundle { + pub const COUNT: usize = TSJS_BUNDLE_COUNT; + + pub const fn filename(self) -> &'static str { + METAS[self as usize].filename + } + + pub fn minified_filename(self) -> String { + let base = self.filename(); + match base.strip_suffix(".js") { + Some(stem) => format!("{stem}.min.js"), + None => format!("{base}.min.js"), + } + } + + pub(crate) const fn bundle(self) -> &'static str { + METAS[self as usize].bundle + } + + pub(crate) fn filename_map() -> &'static std::collections::HashMap<&'static str, TsjsBundle> { + static MAP: std::sync::OnceLock> = std::sync::OnceLock::new(); + + MAP.get_or_init(|| { + ALL_BUNDLES + .iter() + .copied() + .map(|bundle| (bundle.filename(), bundle)) + .collect::>() + }) + } + + pub fn from_filename(name: &str) -> Option { + Self::filename_map().get(name).copied() + } +} pub fn bundle_hash(bundle: TsjsBundle) -> String { hash_bundle(bundle.bundle()) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index d53795b..7da69fc 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -1,4 +1,3 @@ pub mod bundle; -mod macros; pub use bundle::{bundle_for_filename, bundle_hash, bundle_hash_for_filename, TsjsBundle}; diff --git a/crates/js/src/macros.rs b/crates/js/src/macros.rs deleted file mode 100644 index cd9ea21..0000000 --- a/crates/js/src/macros.rs +++ /dev/null @@ -1,68 +0,0 @@ -macro_rules! count_variants { - ($($variant:ident),+ $(,)?) => { - <[()]>::len(&[$(count_variants!(@unit $variant)),+]) - }; - (@unit $variant:ident) => { () }; -} - -macro_rules! define_tsjs_bundles { - ($($variant:ident => $file:expr),+ $(,)?) => { - const TSJS_BUNDLE_COUNT: usize = count_variants!($($variant),+); - - #[repr(usize)] - #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] - pub enum TsjsBundle { - $( $variant ),+ - } - - const METAS: [TsjsMeta; TSJS_BUNDLE_COUNT] = [ - $(TsjsMeta::new($file, include_str!(concat!(env!("OUT_DIR"), "/", $file)))),+ - ]; - - const ALL_BUNDLES: [TsjsBundle; TSJS_BUNDLE_COUNT] = [ - $(TsjsBundle::$variant),+ - ]; - - impl TsjsBundle { - pub const COUNT: usize = TSJS_BUNDLE_COUNT; - - pub const fn filename(self) -> &'static str { - METAS[self as usize].filename - } - - pub fn minified_filename(self) -> String { - let base = self.filename(); - match base.strip_suffix(".js") { - Some(stem) => format!("{stem}.min.js"), - None => format!("{base}.min.js"), - } - } - - pub(crate) const fn bundle(self) -> &'static str { - METAS[self as usize].bundle - } - - pub(crate) fn filename_map( - ) -> &'static ::std::collections::HashMap<&'static str, TsjsBundle> { - static MAP: ::std::sync::OnceLock< - ::std::collections::HashMap<&'static str, TsjsBundle>, - > = ::std::sync::OnceLock::new(); - - MAP.get_or_init(|| { - ALL_BUNDLES - .iter() - .copied() - .map(|bundle| (bundle.filename(), bundle)) - .collect::<::std::collections::HashMap<_, _>>() - }) - } - - pub fn from_filename(name: &str) -> Option { - Self::filename_map().get(name).copied() - } - } - }; -} - -pub(crate) use count_variants; -pub(crate) use define_tsjs_bundles;