Skip to content
Open
8 changes: 4 additions & 4 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,22 @@
"Bash(tree:*)",
"Bash(wc:*)",
"Bash(which:*)",

"Bash(npm ci:*)",
"Bash(npm run:*)",
"Bash(npm test:*)",

"Bash(cargo build:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo fmt:*)",
"Bash(cargo metadata:*)",
"Bash(cargo test:*)",

"Bash(git branch:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git status:*)"
"Bash(git status:*)",
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__new_page",
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__performance_stop_trace",
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__evaluate_script"
]
}
}
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,10 @@ IntegrationRegistration::builder(ID)
.build()
```

- Integration IDs match JS directory names: `prebid`, `lockr`, `permutive`, `datadome`, `didomi`, `testlight`.
- Integration IDs match JS directory names: `prebid` (deferred), `lockr`, `permutive`, `datadome`, `didomi`, `testlight`.
- `creative` is JS-only (no Rust registration); `nextjs`, `aps`, `adserver_mock` are Rust-only.
- `IntegrationRegistry::js_module_ids()` maps registered integrations to JS module names.
- Integrations opt into deferred loading via `.with_deferred_js()` on the registration builder. Deferred modules are served as separate `<script defer>` tags instead of being concatenated into the main bundle.
- `IntegrationRegistry::js_module_ids_immediate()` returns modules for the main bundle; `js_module_ids_deferred()` returns modules loaded with `defer`.

## JS Build Pipeline

Expand Down
2 changes: 1 addition & 1 deletion crates/common/src/creative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ pub fn rewrite_creative_html(settings: &Settings, markup: &str) -> String {
let injected = injected_ts_creative.clone();
move |el| {
if !injected.get() {
let script_tag = tsjs::tsjs_script_tag_all();
let script_tag = tsjs::tsjs_unified_script_tag();
el.prepend(&script_tag, ContentType::Html);
injected.set(true);
}
Expand Down
11 changes: 7 additions & 4 deletions crates/common/src/html_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,13 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
for insert in integrations.head_inserts(&ctx) {
snippet.push_str(&insert);
}
// Then inject the TSJS bundle — its top-level init code can now
// read the config that was set by the inline scripts above.
let module_ids = integrations.js_module_ids();
snippet.push_str(&tsjs::tsjs_script_tag(&module_ids));
// Main bundle: core + non-deferred integrations (synchronous).
let immediate_ids = integrations.js_module_ids_immediate();
snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids));
// Deferred bundles: large modules like prebid loaded after
// HTML parsing completes. Empty when none are enabled.
let deferred_ids = integrations.js_module_ids_deferred();
snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids));
el.prepend(&snippet, ContentType::Html);
injected_tsjs.set(true);
}
Expand Down
11 changes: 8 additions & 3 deletions crates/common/src/integrations/prebid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
.with_proxy(integration.clone())
.with_attribute_rewriter(integration.clone())
.with_head_injector(integration)
.with_deferred_js()
.build(),
)
}
Expand Down Expand Up @@ -320,7 +321,7 @@ impl IntegrationHeadInjector for PrebidIntegration {
.replace("</", "<\\/");

vec![format!(
r#"<script>window.__tsjs_prebid={config_json};</script>"#
r#"<script>window.pbjs=window.pbjs||{{}};window.pbjs.que=window.pbjs.que||[];window.pbjs.cmd=window.pbjs.cmd||[];window.__tsjs_prebid={config_json};</script>"#
)]
}
}
Expand Down Expand Up @@ -1232,13 +1233,17 @@ template = "{{client_ip}}:{{user_agent}}"
"Unified bundle should be injected"
);
assert!(
!processed.contains("prebid.min.js"),
"Prebid script should be removed when auto-config is enabled"
!processed.contains("cdn.prebid.org/prebid.min.js"),
"Publisher 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"
);
assert!(
processed.contains("tsjs-prebid.min.js"),
"Deferred prebid bundle should be injected"
);
}

#[test]
Expand Down
139 changes: 139 additions & 0 deletions crates/common/src/integrations/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ pub trait IntegrationHeadInjector: Send + Sync {
/// Registration payload returned by integration builders.
pub struct IntegrationRegistration {
pub integration_id: &'static str,
pub js_deferred: bool,
pub proxies: Vec<Arc<dyn IntegrationProxy>>,
pub attribute_rewriters: Vec<Arc<dyn IntegrationAttributeRewriter>>,
pub script_rewriters: Vec<Arc<dyn IntegrationScriptRewriter>>,
Expand All @@ -413,6 +414,7 @@ impl IntegrationRegistrationBuilder {
Self {
registration: IntegrationRegistration {
integration_id,
js_deferred: false,
proxies: Vec::new(),
attribute_rewriters: Vec::new(),
script_rewriters: Vec::new(),
Expand Down Expand Up @@ -458,6 +460,14 @@ impl IntegrationRegistrationBuilder {
self
}

/// Mark this integration's JS module for deferred loading via
/// `<script defer>` instead of the main synchronous bundle.
#[must_use]
pub fn with_deferred_js(mut self) -> Self {
self.registration.js_deferred = true;
self
}

#[must_use]
pub fn build(self) -> IntegrationRegistration {
self.registration
Expand All @@ -476,6 +486,7 @@ struct IntegrationRegistryInner {

// Metadata for introspection
routes: Vec<(IntegrationEndpoint, &'static str)>,
deferred_js_ids: Vec<&'static str>,
html_rewriters: Vec<Arc<dyn IntegrationAttributeRewriter>>,
script_rewriters: Vec<Arc<dyn IntegrationScriptRewriter>>,
html_post_processors: Vec<Arc<dyn IntegrationHtmlPostProcessor>>,
Expand All @@ -495,6 +506,7 @@ impl Default for IntegrationRegistryInner {
script_rewriters: Vec::new(),
html_post_processors: Vec::new(),
head_injectors: Vec::new(),
deferred_js_ids: Vec::new(),
}
}
}
Expand Down Expand Up @@ -600,6 +612,9 @@ impl IntegrationRegistry {
inner
.head_injectors
.extend(registration.head_injectors.into_iter());
if registration.js_deferred {
inner.deferred_js_ids.push(registration.integration_id);
}
}
}

Expand Down Expand Up @@ -789,6 +804,30 @@ impl IntegrationRegistry {
ids
}

/// Return JS module IDs for the main (synchronous) bundle, excluding
/// modules registered with [`with_deferred_js`](IntegrationRegistrationBuilder::with_deferred_js).
#[must_use]
pub fn js_module_ids_immediate(&self) -> Vec<&'static str> {
self.js_module_ids()
.into_iter()
.filter(|id| !self.inner.deferred_js_ids.contains(id))
.collect()
}

/// Return JS module IDs that should be loaded with `<script defer>`.
///
/// Only includes modules registered with
/// [`with_deferred_js`](IntegrationRegistrationBuilder::with_deferred_js)
/// that are actually enabled. Returns an empty vec when no deferred
/// integrations are configured.
#[must_use]
pub fn js_module_ids_deferred(&self) -> Vec<&'static str> {
self.js_module_ids()
.into_iter()
.filter(|id| self.inner.deferred_js_ids.contains(id))
.collect()
}

#[cfg(test)]
#[must_use]
pub fn from_rewriters(
Expand All @@ -807,6 +846,7 @@ impl IntegrationRegistry {
script_rewriters,
html_post_processors: Vec::new(),
head_injectors: Vec::new(),
deferred_js_ids: Vec::new(),
}),
}
}
Expand All @@ -830,6 +870,7 @@ impl IntegrationRegistry {
script_rewriters,
html_post_processors: Vec::new(),
head_injectors,
deferred_js_ids: Vec::new(),
}),
}
}
Expand Down Expand Up @@ -885,6 +926,7 @@ impl IntegrationRegistry {
script_rewriters: Vec::new(),
html_post_processors: Vec::new(),
head_injectors: Vec::new(),
deferred_js_ids: Vec::new(),
}),
}
}
Expand Down Expand Up @@ -1338,4 +1380,101 @@ mod tests {
"POST response should have x-synthetic-id header"
);
}

#[test]
fn js_module_ids_immediate_excludes_prebid() {
let settings = crate::test_support::tests::create_test_settings();
let mut settings_with_prebid = settings;
settings_with_prebid
.integrations
.insert_config(
"prebid",
&serde_json::json!({
"enabled": true,
"server_url": "https://test-prebid.com/openrtb2/auction",
"timeout_ms": 1000,
"bidders": ["mocktioneer"],
"debug": false
}),
)
.expect("should insert prebid config");

let registry =
IntegrationRegistry::new(&settings_with_prebid).expect("should create registry");

let all = registry.js_module_ids();
let immediate = registry.js_module_ids_immediate();
let deferred = registry.js_module_ids_deferred();

assert!(
all.contains(&"prebid"),
"should include prebid in full list"
);
assert!(
!immediate.contains(&"prebid"),
"should not include prebid in immediate IDs"
);
assert!(
deferred.contains(&"prebid"),
"should include prebid in deferred IDs"
);
}

#[test]
fn js_module_ids_deferred_empty_when_prebid_disabled() {
let mut settings = crate::test_support::tests::create_test_settings();
settings
.integrations
.insert_config(
"prebid",
&serde_json::json!({
"enabled": false,
"server_url": "https://test-prebid.com/openrtb2/auction"
}),
)
.expect("should update prebid config");

let registry = IntegrationRegistry::new(&settings).expect("should create registry");

let deferred = registry.js_module_ids_deferred();
assert!(
deferred.is_empty(),
"should have no deferred IDs when prebid is disabled"
);
}

#[test]
fn js_module_ids_split_is_exhaustive() {
let settings = crate::test_support::tests::create_test_settings();
let mut settings_with_prebid = settings;
settings_with_prebid
.integrations
.insert_config(
"prebid",
&serde_json::json!({
"enabled": true,
"server_url": "https://test-prebid.com/openrtb2/auction",
"timeout_ms": 1000,
"bidders": ["mocktioneer"],
"debug": false
}),
)
.expect("should insert prebid config");

let registry =
IntegrationRegistry::new(&settings_with_prebid).expect("should create registry");

let all = registry.js_module_ids();
let mut recombined = registry.js_module_ids_immediate();
recombined.extend(registry.js_module_ids_deferred());
recombined.sort();

let mut all_sorted = all;
all_sorted.sort();

assert_eq!(
recombined, all_sorted,
"should reconstruct full module list from immediate + deferred"
);
}
}
9 changes: 5 additions & 4 deletions crates/common/src/integrations/testlight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,9 @@ fn default_timeout_ms() -> u32 {
}

fn default_shim_src() -> String {
// Testlight is included in the unified bundle, so we return the unified script source
tsjs::tsjs_script_src_all()
// Testlight is included in the unified bundle, so we return the unified script source.
// Uses conservative all-module hash since the registry is unavailable at config time.
tsjs::tsjs_unified_script_src()
}

fn default_enabled() -> bool {
Expand Down Expand Up @@ -260,7 +261,7 @@ mod tests {

#[test]
fn html_rewriter_replaces_integration_script() {
let shim_src = tsjs::tsjs_script_src_all();
let shim_src = tsjs::tsjs_unified_script_src();
let config = TestlightConfig {
enabled: true,
endpoint: "https://example.com/openrtb".to_string(),
Expand Down Expand Up @@ -290,7 +291,7 @@ mod tests {

#[test]
fn html_rewriter_is_noop_when_disabled() {
let shim_src = tsjs::tsjs_script_src_all();
let shim_src = tsjs::tsjs_unified_script_src();
let config = TestlightConfig {
enabled: true,
endpoint: "https://example.com/openrtb".to_string(),
Expand Down
Loading
Loading