Skip to content

Commit 68fc04a

Browse files
authored
Split Prebid into deferred bundle to reduce render-blocking JS (#393)
* Split Prebid into deferred bundle to reduce render-blocking JS by 88% Prebid.js (168 KB) was 80% of the TSJS unified bundle, blocking page rendering. Split it into a separate `<script defer>` tag so the critical-path bundle drops from 190 KB to 22 KB. Changes across 8 files: - crates/js/src/bundle.rs: add `single_module_hash()` and `all_module_ids_excluding()` for deferred module serving - crates/js/src/lib.rs: export new bundle functions - crates/common/src/tsjs.rs: add `DEFERRED_MODULE_IDS` constant (single source of truth), deferred script tag generation functions, update `_all()` variants to exclude deferred modules - crates/common/src/integrations/registry.rs: add `js_module_ids_immediate()` and `js_module_ids_deferred()` with tests - crates/common/src/html_processor.rs: inject split tags at `<head>`: synchronous main bundle + deferred prebid tag - crates/common/src/publisher.rs: serve deferred modules at `/static/tsjs=tsjs-{id}.min.js` with allowlist validation and tests - crates/common/src/integrations/prebid.rs: fix test assertion for new deferred tag presence - docs/guide/integration-guide.md: update Prebid load timing docs Closes #358 * Document deferred vs immediate loading for each integration Update CLAUDE.md and integration-guide.md to indicate which integrations use deferred loading and explain the two loading modes. * Replace hardcoded DEFERRED_MODULE_IDS with per-integration builder flag Each integration now declares deferred JS loading via .with_deferred_js() on IntegrationRegistrationBuilder, replacing the static constant that required manual maintenance. Removes unused _all() convenience functions from tsjs.rs and all_module_ids_excluding() from bundle.rs. * Fixed formatting of settings.json * Fixed formatting * Fix cache-busting hash to reflect full unified bundle content tsjs_script_src(&["testlight"]) hashed only core+testlight, but the unified bundle contains all immediate modules. Added tsjs_unified_script_src() and tsjs_unified_script_tag() that hash all module IDs so the cache key matches the actual served content. * Bootstrap pbjs globals before deferred Prebid bundle loads Inject window.pbjs, pbjs.que, and pbjs.cmd in the head injector inline script so publisher pages with bare pbjs.que.push() calls don't throw before the deferred bundle executes. Also fix stale doc references that still described Prebid as part of the unified sync bundle. * Added #[must_use]
1 parent 7c3e187 commit 68fc04a

12 files changed

Lines changed: 385 additions & 46 deletions

File tree

.claude/settings.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,22 @@
1010
"Bash(tree:*)",
1111
"Bash(wc:*)",
1212
"Bash(which:*)",
13-
1413
"Bash(npm ci:*)",
1514
"Bash(npm run:*)",
1615
"Bash(npm test:*)",
17-
1816
"Bash(cargo build:*)",
1917
"Bash(cargo check:*)",
2018
"Bash(cargo clippy:*)",
2119
"Bash(cargo fmt:*)",
2220
"Bash(cargo metadata:*)",
2321
"Bash(cargo test:*)",
24-
2522
"Bash(git branch:*)",
2623
"Bash(git diff:*)",
2724
"Bash(git log:*)",
28-
"Bash(git status:*)"
25+
"Bash(git status:*)",
26+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__new_page",
27+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__performance_stop_trace",
28+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__evaluate_script"
2929
]
3030
}
3131
}

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,10 @@ IntegrationRegistration::builder(ID)
237237
.build()
238238
```
239239

240-
- Integration IDs match JS directory names: `prebid`, `lockr`, `permutive`, `datadome`, `didomi`, `testlight`.
240+
- Integration IDs match JS directory names: `prebid` (deferred), `lockr`, `permutive`, `datadome`, `didomi`, `testlight`.
241241
- `creative` is JS-only (no Rust registration); `nextjs`, `aps`, `adserver_mock` are Rust-only.
242-
- `IntegrationRegistry::js_module_ids()` maps registered integrations to JS module names.
242+
- 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.
243+
- `IntegrationRegistry::js_module_ids_immediate()` returns modules for the main bundle; `js_module_ids_deferred()` returns modules loaded with `defer`.
243244

244245
## JS Build Pipeline
245246

crates/common/src/creative.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ pub fn rewrite_creative_html(settings: &Settings, markup: &str) -> String {
322322
let injected = injected_ts_creative.clone();
323323
move |el| {
324324
if !injected.get() {
325-
let script_tag = tsjs::tsjs_script_tag_all();
325+
let script_tag = tsjs::tsjs_unified_script_tag();
326326
el.prepend(&script_tag, ContentType::Html);
327327
injected.set(true);
328328
}

crates/common/src/html_processor.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,13 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
208208
for insert in integrations.head_inserts(&ctx) {
209209
snippet.push_str(&insert);
210210
}
211-
// Then inject the TSJS bundle — its top-level init code can now
212-
// read the config that was set by the inline scripts above.
213-
let module_ids = integrations.js_module_ids();
214-
snippet.push_str(&tsjs::tsjs_script_tag(&module_ids));
211+
// Main bundle: core + non-deferred integrations (synchronous).
212+
let immediate_ids = integrations.js_module_ids_immediate();
213+
snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids));
214+
// Deferred bundles: large modules like prebid loaded after
215+
// HTML parsing completes. Empty when none are enabled.
216+
let deferred_ids = integrations.js_module_ids_deferred();
217+
snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids));
215218
el.prepend(&snippet, ContentType::Html);
216219
injected_tsjs.set(true);
217220
}

crates/common/src/integrations/prebid.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
229229
.with_proxy(integration.clone())
230230
.with_attribute_rewriter(integration.clone())
231231
.with_head_injector(integration)
232+
.with_deferred_js()
232233
.build(),
233234
)
234235
}
@@ -320,7 +321,7 @@ impl IntegrationHeadInjector for PrebidIntegration {
320321
.replace("</", "<\\/");
321322

322323
vec![format!(
323-
r#"<script>window.__tsjs_prebid={config_json};</script>"#
324+
r#"<script>window.pbjs=window.pbjs||{{}};window.pbjs.que=window.pbjs.que||[];window.pbjs.cmd=window.pbjs.cmd||[];window.__tsjs_prebid={config_json};</script>"#
324325
)]
325326
}
326327
}
@@ -1232,13 +1233,17 @@ template = "{{client_ip}}:{{user_agent}}"
12321233
"Unified bundle should be injected"
12331234
);
12341235
assert!(
1235-
!processed.contains("prebid.min.js"),
1236-
"Prebid script should be removed when auto-config is enabled"
1236+
!processed.contains("cdn.prebid.org/prebid.min.js"),
1237+
"Publisher prebid script should be removed when auto-config is enabled"
12371238
);
12381239
assert!(
12391240
!processed.contains("cdn.prebid.org/prebid.js"),
12401241
"Prebid preload should be removed when auto-config is enabled"
12411242
);
1243+
assert!(
1244+
processed.contains("tsjs-prebid.min.js"),
1245+
"Deferred prebid bundle should be injected"
1246+
);
12421247
}
12431248

12441249
#[test]

crates/common/src/integrations/registry.rs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ pub trait IntegrationHeadInjector: Send + Sync {
390390
/// Registration payload returned by integration builders.
391391
pub struct IntegrationRegistration {
392392
pub integration_id: &'static str,
393+
pub js_deferred: bool,
393394
pub proxies: Vec<Arc<dyn IntegrationProxy>>,
394395
pub attribute_rewriters: Vec<Arc<dyn IntegrationAttributeRewriter>>,
395396
pub script_rewriters: Vec<Arc<dyn IntegrationScriptRewriter>>,
@@ -413,6 +414,7 @@ impl IntegrationRegistrationBuilder {
413414
Self {
414415
registration: IntegrationRegistration {
415416
integration_id,
417+
js_deferred: false,
416418
proxies: Vec::new(),
417419
attribute_rewriters: Vec::new(),
418420
script_rewriters: Vec::new(),
@@ -458,6 +460,14 @@ impl IntegrationRegistrationBuilder {
458460
self
459461
}
460462

463+
/// Mark this integration's JS module for deferred loading via
464+
/// `<script defer>` instead of the main synchronous bundle.
465+
#[must_use]
466+
pub fn with_deferred_js(mut self) -> Self {
467+
self.registration.js_deferred = true;
468+
self
469+
}
470+
461471
#[must_use]
462472
pub fn build(self) -> IntegrationRegistration {
463473
self.registration
@@ -476,6 +486,7 @@ struct IntegrationRegistryInner {
476486

477487
// Metadata for introspection
478488
routes: Vec<(IntegrationEndpoint, &'static str)>,
489+
deferred_js_ids: Vec<&'static str>,
479490
html_rewriters: Vec<Arc<dyn IntegrationAttributeRewriter>>,
480491
script_rewriters: Vec<Arc<dyn IntegrationScriptRewriter>>,
481492
html_post_processors: Vec<Arc<dyn IntegrationHtmlPostProcessor>>,
@@ -495,6 +506,7 @@ impl Default for IntegrationRegistryInner {
495506
script_rewriters: Vec::new(),
496507
html_post_processors: Vec::new(),
497508
head_injectors: Vec::new(),
509+
deferred_js_ids: Vec::new(),
498510
}
499511
}
500512
}
@@ -600,6 +612,9 @@ impl IntegrationRegistry {
600612
inner
601613
.head_injectors
602614
.extend(registration.head_injectors.into_iter());
615+
if registration.js_deferred {
616+
inner.deferred_js_ids.push(registration.integration_id);
617+
}
603618
}
604619
}
605620

@@ -789,6 +804,30 @@ impl IntegrationRegistry {
789804
ids
790805
}
791806

807+
/// Return JS module IDs for the main (synchronous) bundle, excluding
808+
/// modules registered with [`with_deferred_js`](IntegrationRegistrationBuilder::with_deferred_js).
809+
#[must_use]
810+
pub fn js_module_ids_immediate(&self) -> Vec<&'static str> {
811+
self.js_module_ids()
812+
.into_iter()
813+
.filter(|id| !self.inner.deferred_js_ids.contains(id))
814+
.collect()
815+
}
816+
817+
/// Return JS module IDs that should be loaded with `<script defer>`.
818+
///
819+
/// Only includes modules registered with
820+
/// [`with_deferred_js`](IntegrationRegistrationBuilder::with_deferred_js)
821+
/// that are actually enabled. Returns an empty vec when no deferred
822+
/// integrations are configured.
823+
#[must_use]
824+
pub fn js_module_ids_deferred(&self) -> Vec<&'static str> {
825+
self.js_module_ids()
826+
.into_iter()
827+
.filter(|id| self.inner.deferred_js_ids.contains(id))
828+
.collect()
829+
}
830+
792831
#[cfg(test)]
793832
#[must_use]
794833
pub fn from_rewriters(
@@ -807,6 +846,7 @@ impl IntegrationRegistry {
807846
script_rewriters,
808847
html_post_processors: Vec::new(),
809848
head_injectors: Vec::new(),
849+
deferred_js_ids: Vec::new(),
810850
}),
811851
}
812852
}
@@ -830,6 +870,7 @@ impl IntegrationRegistry {
830870
script_rewriters,
831871
html_post_processors: Vec::new(),
832872
head_injectors,
873+
deferred_js_ids: Vec::new(),
833874
}),
834875
}
835876
}
@@ -885,6 +926,7 @@ impl IntegrationRegistry {
885926
script_rewriters: Vec::new(),
886927
html_post_processors: Vec::new(),
887928
head_injectors: Vec::new(),
929+
deferred_js_ids: Vec::new(),
888930
}),
889931
}
890932
}
@@ -1338,4 +1380,101 @@ mod tests {
13381380
"POST response should have x-synthetic-id header"
13391381
);
13401382
}
1383+
1384+
#[test]
1385+
fn js_module_ids_immediate_excludes_prebid() {
1386+
let settings = crate::test_support::tests::create_test_settings();
1387+
let mut settings_with_prebid = settings;
1388+
settings_with_prebid
1389+
.integrations
1390+
.insert_config(
1391+
"prebid",
1392+
&serde_json::json!({
1393+
"enabled": true,
1394+
"server_url": "https://test-prebid.com/openrtb2/auction",
1395+
"timeout_ms": 1000,
1396+
"bidders": ["mocktioneer"],
1397+
"debug": false
1398+
}),
1399+
)
1400+
.expect("should insert prebid config");
1401+
1402+
let registry =
1403+
IntegrationRegistry::new(&settings_with_prebid).expect("should create registry");
1404+
1405+
let all = registry.js_module_ids();
1406+
let immediate = registry.js_module_ids_immediate();
1407+
let deferred = registry.js_module_ids_deferred();
1408+
1409+
assert!(
1410+
all.contains(&"prebid"),
1411+
"should include prebid in full list"
1412+
);
1413+
assert!(
1414+
!immediate.contains(&"prebid"),
1415+
"should not include prebid in immediate IDs"
1416+
);
1417+
assert!(
1418+
deferred.contains(&"prebid"),
1419+
"should include prebid in deferred IDs"
1420+
);
1421+
}
1422+
1423+
#[test]
1424+
fn js_module_ids_deferred_empty_when_prebid_disabled() {
1425+
let mut settings = crate::test_support::tests::create_test_settings();
1426+
settings
1427+
.integrations
1428+
.insert_config(
1429+
"prebid",
1430+
&serde_json::json!({
1431+
"enabled": false,
1432+
"server_url": "https://test-prebid.com/openrtb2/auction"
1433+
}),
1434+
)
1435+
.expect("should update prebid config");
1436+
1437+
let registry = IntegrationRegistry::new(&settings).expect("should create registry");
1438+
1439+
let deferred = registry.js_module_ids_deferred();
1440+
assert!(
1441+
deferred.is_empty(),
1442+
"should have no deferred IDs when prebid is disabled"
1443+
);
1444+
}
1445+
1446+
#[test]
1447+
fn js_module_ids_split_is_exhaustive() {
1448+
let settings = crate::test_support::tests::create_test_settings();
1449+
let mut settings_with_prebid = settings;
1450+
settings_with_prebid
1451+
.integrations
1452+
.insert_config(
1453+
"prebid",
1454+
&serde_json::json!({
1455+
"enabled": true,
1456+
"server_url": "https://test-prebid.com/openrtb2/auction",
1457+
"timeout_ms": 1000,
1458+
"bidders": ["mocktioneer"],
1459+
"debug": false
1460+
}),
1461+
)
1462+
.expect("should insert prebid config");
1463+
1464+
let registry =
1465+
IntegrationRegistry::new(&settings_with_prebid).expect("should create registry");
1466+
1467+
let all = registry.js_module_ids();
1468+
let mut recombined = registry.js_module_ids_immediate();
1469+
recombined.extend(registry.js_module_ids_deferred());
1470+
recombined.sort();
1471+
1472+
let mut all_sorted = all;
1473+
all_sorted.sort();
1474+
1475+
assert_eq!(
1476+
recombined, all_sorted,
1477+
"should reconstruct full module list from immediate + deferred"
1478+
);
1479+
}
13411480
}

crates/common/src/integrations/testlight.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,9 @@ fn default_timeout_ms() -> u32 {
218218
}
219219

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

225226
fn default_enabled() -> bool {
@@ -260,7 +261,7 @@ mod tests {
260261

261262
#[test]
262263
fn html_rewriter_replaces_integration_script() {
263-
let shim_src = tsjs::tsjs_script_src_all();
264+
let shim_src = tsjs::tsjs_unified_script_src();
264265
let config = TestlightConfig {
265266
enabled: true,
266267
endpoint: "https://example.com/openrtb".to_string(),
@@ -290,7 +291,7 @@ mod tests {
290291

291292
#[test]
292293
fn html_rewriter_is_noop_when_disabled() {
293-
let shim_src = tsjs::tsjs_script_src_all();
294+
let shim_src = tsjs::tsjs_unified_script_src();
294295
let config = TestlightConfig {
295296
enabled: true,
296297
endpoint: "https://example.com/openrtb".to_string(),

0 commit comments

Comments
 (0)