diff --git a/crates/common/src/creative.rs b/crates/common/src/creative.rs
index c6c57f1..3953b2e 100644
--- a/crates/common/src/creative.rs
+++ b/crates/common/src/creative.rs
@@ -300,12 +300,12 @@ 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
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 +490,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..e2f5f8e 100644
--- a/crates/common/src/html_processor.rs
+++ b/crates/common/src/html_processor.rs
@@ -2,7 +2,6 @@
//!
//! 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};
@@ -25,7 +24,6 @@ pub struct HtmlProcessorConfig {
pub integrations: IntegrationRegistry,
pub nextjs_enabled: bool,
pub nextjs_attributes: Vec,
- pub integration_assets: Vec,
}
impl HtmlProcessorConfig {
@@ -37,12 +35,6 @@ impl HtmlProcessorConfig {
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(),
@@ -51,7 +43,6 @@ impl HtmlProcessorConfig {
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,8 +120,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();
@@ -145,27 +134,19 @@ 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;
@@ -174,7 +155,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
if let Some(mut href) = el.get_attribute("href") {
let original_href = href.clone();
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())
@@ -204,6 +185,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
Ok(())
}
}),
+ // Replace URLs in src attributes
element!("[src]", {
let patterns = patterns.clone();
let rewrite_prebid = config.enable_prebid;
@@ -212,7 +194,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
if let Some(mut src) = el.get_attribute("src") {
let original_src = src.clone();
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())
@@ -242,6 +224,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
Ok(())
}
}),
+ // Replace URLs in action attributes
element!("[action]", {
let patterns = patterns.clone();
let integrations = integration_registry.clone();
@@ -275,6 +258,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
Ok(())
}
}),
+ // Replace URLs in srcset attributes (for responsive images)
element!("[srcset]", {
let patterns = patterns.clone();
let integrations = integration_registry.clone();
@@ -313,6 +297,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
Ok(())
}
}),
+ // Replace URLs in imagesrcset attributes (for link preload)
element!("[imagesrcset]", {
let patterns = patterns.clone();
let integrations = integration_registry.clone();
@@ -405,28 +390,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,24 +400,8 @@ 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(),
@@ -464,19 +411,18 @@ mod tests {
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 +435,20 @@ 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"));
+ 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 +461,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 +488,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 +662,10 @@ mod tests {
use crate::test_support::tests::create_test_settings;
let settings = create_test_settings();
- let registry = IntegrationRegistry::new(&settings);
+ let integrations = IntegrationRegistry::default();
let config = HtmlProcessorConfig::from_settings(
&settings,
- ®istry,
+ &integrations,
"origin.test-publisher.com",
"proxy.example.com",
"https",
@@ -808,58 +758,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/integrations/registry.rs b/crates/common/src/integrations/registry.rs
index 762ec83..f3ea428 100644
--- a/crates/common/src/integrations/registry.rs
+++ b/crates/common/src/integrations/registry.rs
@@ -103,7 +103,6 @@ pub struct IntegrationRegistration {
pub proxies: Vec>,
pub attribute_rewriters: Vec>,
pub script_rewriters: Vec>,
- pub assets: Vec,
}
impl IntegrationRegistration {
@@ -125,7 +124,6 @@ impl IntegrationRegistrationBuilder {
proxies: Vec::new(),
attribute_rewriters: Vec::new(),
script_rewriters: Vec::new(),
- assets: Vec::new(),
},
}
}
@@ -151,12 +149,6 @@ impl IntegrationRegistrationBuilder {
self
}
- #[must_use]
- pub fn with_asset(mut self, asset: impl Into) -> Self {
- self.registration.assets.push(asset.into());
- self
- }
-
#[must_use]
pub fn build(self) -> IntegrationRegistration {
self.registration
@@ -172,7 +164,6 @@ struct IntegrationRegistryInner {
routes: Vec<(IntegrationEndpoint, &'static str)>,
html_rewriters: Vec>,
script_rewriters: Vec>,
- assets: Vec<(&'static str, String)>,
}
/// Summary of registered integration capabilities.
@@ -182,7 +173,6 @@ pub struct IntegrationMetadata {
pub routes: Vec,
pub attribute_rewriters: usize,
pub script_selectors: Vec<&'static str>,
- pub assets: Vec,
}
impl IntegrationMetadata {
@@ -192,7 +182,6 @@ impl IntegrationMetadata {
routes: Vec::new(),
attribute_rewriters: 0,
script_selectors: Vec::new(),
- assets: Vec::new(),
}
}
}
@@ -234,12 +223,6 @@ impl IntegrationRegistry {
inner
.script_rewriters
.extend(registration.script_rewriters.into_iter());
- inner.assets.extend(
- registration
- .assets
- .into_iter()
- .map(|asset| (registration.integration_id, asset)),
- );
}
}
@@ -332,13 +315,6 @@ impl IntegrationRegistry {
entry.script_selectors.push(rewriter.selector());
}
- for (integration_id, asset) in &self.inner.assets {
- let entry = map
- .entry(*integration_id)
- .or_insert_with(|| IntegrationMetadata::new(integration_id));
- entry.assets.push(asset.clone());
- }
-
map.into_values().collect()
}
}
diff --git a/crates/common/src/integrations/testlight.rs b/crates/common/src/integrations/testlight.rs
index a216fd4..a506ac6 100644
--- a/crates/common/src/integrations/testlight.rs
+++ b/crates/common/src/integrations/testlight.rs
@@ -115,7 +115,6 @@ pub fn register(settings: &Settings) -> Option {
IntegrationRegistration::builder(TESTLIGHT_INTEGRATION_ID)
.with_proxy(integration.clone())
.with_attribute_rewriter(integration)
- .with_asset("testlight")
.build(),
)
}
@@ -201,6 +200,10 @@ impl IntegrationAttributeRewriter for TestlightIntegration {
let lowered = attr_value.to_ascii_lowercase();
if lowered.contains("testlight.js") {
+ // TODO: need a way to remove the whole script tag
+ // None will still load external script Some will only rewrite attr
+ // but testlight script is now backed into the unified build
+ // for now this is loading the unified js again.
Some(self.config.shim_src.clone())
} else {
None
@@ -213,7 +216,8 @@ fn default_timeout_ms() -> u32 {
}
fn default_shim_src() -> String {
- tsjs::integration_script_src("testlight")
+ // Testlight is included in the unified bundle, so we return the unified script source
+ tsjs::unified_script_src()
}
fn default_enabled() -> bool {
@@ -243,21 +247,6 @@ mod tests {
use fastly::http::Method;
use serde_json::json;
- 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();
- }
- }
-
#[test]
fn build_requires_config() {
let settings = create_test_settings();
@@ -269,8 +258,7 @@ mod tests {
#[test]
fn html_rewriter_replaces_integration_script() {
- let _bundle_guard = mock_testlight_bundle();
- let shim_src = tsjs::integration_script_src("testlight");
+ let shim_src = tsjs::unified_script_src();
let config = TestlightConfig {
enabled: true,
endpoint: "https://example.com/openrtb".to_string(),
@@ -298,8 +286,7 @@ mod tests {
#[test]
fn html_rewriter_is_noop_when_disabled() {
- let _bundle_guard = mock_testlight_bundle();
- let shim_src = tsjs::integration_script_src("testlight");
+ let shim_src = tsjs::unified_script_src();
let config = TestlightConfig {
enabled: true,
endpoint: "https://example.com/openrtb".to_string(),
diff --git a/crates/common/src/tsjs.rs b/crates/common/src/tsjs.rs
index ffb1e68..26690da 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,17 @@ 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.
+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/build.rs b/crates/js/build.rs
index e45abff..ba3fe57 100644
--- a/crates/js/build.rs
+++ b/crates/js/build.rs
@@ -1,10 +1,9 @@
use std::env;
-use std::fs::{self, File};
-use std::io::Write;
+use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
-const REQUIRED_BUNDLES: &[&str] = &["tsjs-core.js", "tsjs-ext.js", "tsjs-creative.js"];
+const UNIFIED_BUNDLE: &str = "tsjs-unified.js";
fn main() {
// Rebuild if TS sources change (belt-and-suspenders): enumerate every file under ts/
@@ -61,11 +60,15 @@ fn main() {
.status();
}
- // Build bundle
+ // Build unified bundle
if !skip {
if let Some(npm_path) = npm.clone() {
- let status = Command::new(npm_path)
- .args(["run", "build"])
+ println!("cargo:warning=tsjs: Building unified bundle");
+ let js_modules = env::var("TSJS_MODULES").unwrap_or("".to_string());
+
+ let status = Command::new(&npm_path)
+ .env("TSJS_MODULES", js_modules)
+ .args(["run", "build:custom"])
.current_dir(&ts_dir)
.status();
if !status.as_ref().map(|s| s.success()).unwrap_or(false) {
@@ -74,29 +77,40 @@ fn main() {
}
}
- // Copy the result into OUT_DIR for include_str!
- let bundle_files = discover_bundles(&dist_dir);
- ensure_required_bundles(&bundle_files);
- copy_bundles(&bundle_files, &dist_dir, &out_dir);
- generate_manifest(&bundle_files, &out_dir);
+ // Copy unified bundle into OUT_DIR for include_str!
+ copy_bundle(UNIFIED_BUNDLE, true, &crate_dir, &dist_dir, &out_dir);
}
-fn discover_bundles(dist_dir: &Path) -> Vec {
- let mut bundles = Vec::new();
- let entries = match fs::read_dir(dist_dir) {
- Ok(entries) => entries,
- Err(_) => return bundles,
- };
- for entry in entries.flatten() {
- let path = entry.path();
- if path.extension().and_then(|ext| ext.to_str()) == Some("js") {
- if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
- bundles.push(name.to_string());
+fn copy_bundle(
+ filename: &str,
+ required: bool,
+ crate_dir: &Path,
+ dist_dir: &Path,
+ out_dir: &Path,
+) {
+ let primary = dist_dir.join(filename);
+ let fallback = crate_dir.join("dist").join(filename);
+ let target = out_dir.join(filename);
+
+ for source in [&primary, &fallback] {
+ if source.exists() {
+ if let Err(e) = fs::copy(source, &target) {
+ if required {
+ panic!("tsjs: failed to copy {:?} to {:?}: {}", source, target, e);
+ }
}
+ return;
}
}
- bundles.sort();
- bundles
+
+ if required {
+ panic!(
+ "tsjs: bundle {} not found: {:?} (and fallback {:?}). Ensure Node is installed and `npm run build` succeeds, or commit dist/{}.",
+ filename, primary, fallback, filename
+ );
+ }
+
+ let _ = fs::write(&target, "");
}
fn watch_dir_recursively(root: &Path) {
@@ -121,40 +135,3 @@ fn watch_dir_recursively(root: &Path) {
}
}
}
-
-fn ensure_required_bundles(bundles: &[String]) {
- for required in REQUIRED_BUNDLES {
- if !bundles.iter().any(|bundle| bundle == required) {
- panic!("tsjs: required bundle {} not found in dist/", required);
- }
- }
-}
-
-fn copy_bundles(bundles: &[String], dist_dir: &Path, out_dir: &Path) {
- for bundle in bundles {
- let source = dist_dir.join(bundle);
- let target = out_dir.join(bundle);
- if let Err(e) = fs::copy(&source, &target) {
- panic!(
- "tsjs: failed to copy bundle {:?} to {:?}: {}",
- source, target, e
- );
- }
- }
-}
-
-fn generate_manifest(bundles: &[String], out_dir: &Path) {
- let manifest_path = out_dir.join("bundle_manifest.rs");
- let mut file = File::create(&manifest_path)
- .unwrap_or_else(|e| panic!("tsjs: failed to create manifest: {}", e));
- writeln!(&mut file, "pub const BUNDLES: &[(&str, &str)] = &[").unwrap();
- for bundle in bundles {
- writeln!(
- &mut file,
- " (\"{name}\", include_str!(concat!(env!(\"OUT_DIR\"), \"/{name}\"))),",
- name = bundle
- )
- .unwrap();
- }
- writeln!(&mut file, "];").unwrap();
-}
diff --git a/crates/js/lib/.gitignore b/crates/js/lib/.gitignore
new file mode 100644
index 0000000..f1283e5
--- /dev/null
+++ b/crates/js/lib/.gitignore
@@ -0,0 +1,2 @@
+# Auto-generated files
+src/generated-modules.ts
diff --git a/crates/js/lib/package-lock.json b/crates/js/lib/package-lock.json
index 6e0c2d6..33c2aa4 100644
--- a/crates/js/lib/package-lock.json
+++ b/crates/js/lib/package-lock.json
@@ -166,7 +166,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -210,7 +209,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -1409,7 +1407,6 @@
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.4",
"@typescript-eslint/types": "8.46.4",
@@ -1726,7 +1723,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2032,7 +2028,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -2720,7 +2715,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5529,7 +5523,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -5746,7 +5739,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5945,7 +5937,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/crates/js/lib/package.json b/crates/js/lib/package.json
index 5538f45..93164d3 100644
--- a/crates/js/lib/package.json
+++ b/crates/js/lib/package.json
@@ -5,8 +5,9 @@
"type": "module",
"description": "Trusted Server tsjs TypeScript library with queue and simple banner rendering.",
"scripts": {
- "build": "node scripts/build-all.mjs",
- "dev": "node scripts/watch-all.mjs",
+ "build": "vite build",
+ "build:custom": "vite build",
+ "dev": "vite build --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
diff --git a/crates/js/lib/scripts/build-all.mjs b/crates/js/lib/scripts/build-all.mjs
deleted file mode 100644
index f327680..0000000
--- a/crates/js/lib/scripts/build-all.mjs
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/usr/bin/env node
-
-import { spawnSync } from 'node:child_process';
-import { readdirSync, existsSync } from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const projectRoot = path.resolve(__dirname, '..');
-const integrationsDir = path.resolve(projectRoot, 'src', 'integrations');
-
-function discoverIntegrationBundles() {
- if (!existsSync(integrationsDir)) {
- return [];
- }
- return readdirSync(integrationsDir, { withFileTypes: true })
- .filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
- .map((entry) => entry.name.replace(/\.ts$/i, ''));
-}
-
-function runBundle(key) {
- const env = { ...process.env, TSJS_BUNDLE: key };
- const result = spawnSync('npx', ['vite', 'build'], {
- cwd: projectRoot,
- env,
- stdio: 'inherit',
- });
- if (result.status !== 0) {
- process.exit(result.status ?? 1);
- }
-}
-
-const baseBundles = ['core', 'ext', 'creative'];
-const integrationBundles = discoverIntegrationBundles().map((name) => `integration:${name}`);
-const bundles = [...baseBundles, ...integrationBundles];
-
-if (bundles.length === 0) {
- console.warn('tsjs: no bundles discovered; skipping build');
- process.exit(0);
-}
-
-for (const bundle of bundles) {
- console.log(`tsjs: building bundle "${bundle}"`);
- runBundle(bundle);
-}
diff --git a/crates/js/lib/scripts/watch-all.mjs b/crates/js/lib/scripts/watch-all.mjs
deleted file mode 100755
index 6434afe..0000000
--- a/crates/js/lib/scripts/watch-all.mjs
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/usr/bin/env node
-import { spawn } from 'node:child_process';
-
-const modes = ['core', 'ext', 'creative'];
-const children = [];
-
-function spawnWatcher(mode) {
- const child = spawn('npx', ['vite', 'build', '--mode', mode, '--watch'], {
- stdio: 'inherit',
- env: process.env,
- });
- child.on('exit', (code, signal) => {
- if (signal) {
- console.log(`[watch] mode ${mode} exited via signal ${signal}`);
- } else {
- console.log(`[watch] mode ${mode} exited with code ${code}`);
- }
- });
- children.push(child);
-}
-
-modes.forEach(spawnWatcher);
-
-function shutdown(signal) {
- console.log(`\n[watch] received ${signal}; shutting down watchers...`);
- for (const child of children) {
- if (!child.killed) child.kill('SIGTERM');
- }
- process.exit(0);
-}
-
-process.on('SIGINT', () => shutdown('SIGINT'));
-process.on('SIGTERM', () => shutdown('SIGTERM'));
diff --git a/crates/js/lib/src/index.ts b/crates/js/lib/src/index.ts
new file mode 100644
index 0000000..7cd2a5e
--- /dev/null
+++ b/crates/js/lib/src/index.ts
@@ -0,0 +1,48 @@
+// Unified tsjs bundle entry point
+// This file conditionally imports modules based on build-time configuration
+import type { TsjsApi } from './core/types';
+import { modules, type ModuleName } from './generated-modules';
+import { log } from './core/log';
+
+const VERSION = '0.1.0-unified';
+
+// Ensure we have a window object
+const w: Window & { tsjs?: TsjsApi } =
+ ((globalThis as unknown as { window?: Window }).window as Window & {
+ tsjs?: TsjsApi;
+ }) || ({} as Window & { tsjs?: TsjsApi });
+
+// Log which modules are included in this build
+const includedModules = Object.keys(modules) as ModuleName[];
+log.info('tsjs unified bundle initialized', {
+ version: VERSION,
+ modules: includedModules,
+});
+
+// The core module sets up the main API and should always be included
+// If core is included, it will have already initialized the tsjs global
+// and set up the queue system
+
+// Initialize optional modules if they're included
+for (const [moduleName, moduleExports] of Object.entries(modules)) {
+ if (moduleName === 'core') {
+ // Core is already initialized via its own IIFE-style init code
+ continue;
+ }
+
+ // For other modules, check if they have an init function or are self-initializing
+ if (typeof moduleExports === 'object' && moduleExports !== null) {
+ // Log that the module is available
+ log.debug(`tsjs: module '${moduleName}' loaded`);
+
+ // 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
+ }
+}
+
+// Re-export core types for convenience
+export type { AdUnit, TsjsApi } from './core/types';
+
+// Export the modules object for advanced use cases
+export { modules };
diff --git a/crates/js/lib/src/creative/click.ts b/crates/js/lib/src/integrations/creative/click.ts
similarity index 98%
rename from crates/js/lib/src/creative/click.ts
rename to crates/js/lib/src/integrations/creative/click.ts
index dc5289a..a505c03 100644
--- a/crates/js/lib/src/creative/click.ts
+++ b/crates/js/lib/src/integrations/creative/click.ts
@@ -1,8 +1,8 @@
// Click guard runtime: detects mutated tracking URLs and rebuilds signed first-party clicks.
-import { log } from '../core/log';
-import { creativeGlobal } from '../shared/globals';
-import { delay, queueTask } from '../shared/async';
-import { createMutationScheduler } from '../shared/scheduler';
+import { log } from '../../core/log';
+import { creativeGlobal } from '../../shared/globals';
+import { delay, queueTask } from '../../shared/async';
+import { createMutationScheduler } from '../../shared/scheduler';
type AnchorLike = HTMLAnchorElement | HTMLAreaElement;
type Canon = { base: string; params: Record };
diff --git a/crates/js/lib/src/creative/dynamic_src_guard.ts b/crates/js/lib/src/integrations/creative/dynamic_src_guard.ts
similarity index 99%
rename from crates/js/lib/src/creative/dynamic_src_guard.ts
rename to crates/js/lib/src/integrations/creative/dynamic_src_guard.ts
index 569e0ef..2fc216c 100644
--- a/crates/js/lib/src/creative/dynamic_src_guard.ts
+++ b/crates/js/lib/src/integrations/creative/dynamic_src_guard.ts
@@ -1,5 +1,5 @@
-import { log } from '../core/log';
-import { createMutationScheduler } from '../shared/scheduler';
+import { log } from '../../core/log';
+import { createMutationScheduler } from '../../shared/scheduler';
type ElementWithSrc = Element & { src: string };
diff --git a/crates/js/lib/src/creative/iframe.ts b/crates/js/lib/src/integrations/creative/iframe.ts
similarity index 100%
rename from crates/js/lib/src/creative/iframe.ts
rename to crates/js/lib/src/integrations/creative/iframe.ts
diff --git a/crates/js/lib/src/creative/image.ts b/crates/js/lib/src/integrations/creative/image.ts
similarity index 100%
rename from crates/js/lib/src/creative/image.ts
rename to crates/js/lib/src/integrations/creative/image.ts
diff --git a/crates/js/lib/src/creative/index.ts b/crates/js/lib/src/integrations/creative/index.ts
similarity index 95%
rename from crates/js/lib/src/creative/index.ts
rename to crates/js/lib/src/integrations/creative/index.ts
index 2f16bbb..3955535 100644
--- a/crates/js/lib/src/creative/index.ts
+++ b/crates/js/lib/src/integrations/creative/index.ts
@@ -1,7 +1,7 @@
// Entry point for the creative runtime: wires up click + image + iframe guards globally.
-import { log } from '../core/log';
-import type { TsCreativeConfig, CreativeWindow, TsCreativeApi } from '../shared/globals';
-import { creativeGlobal, resolveWindow } from '../shared/globals';
+import { log } from '../../core/log';
+import type { TsCreativeConfig, CreativeWindow, TsCreativeApi } from '../../shared/globals';
+import { creativeGlobal, resolveWindow } from '../../shared/globals';
import { installClickGuard } from './click';
import { installDynamicImageProxy } from './image';
diff --git a/crates/js/lib/src/creative/proxy_sign.ts b/crates/js/lib/src/integrations/creative/proxy_sign.ts
similarity index 97%
rename from crates/js/lib/src/creative/proxy_sign.ts
rename to crates/js/lib/src/integrations/creative/proxy_sign.ts
index fc89acc..b18d7d1 100644
--- a/crates/js/lib/src/creative/proxy_sign.ts
+++ b/crates/js/lib/src/integrations/creative/proxy_sign.ts
@@ -1,4 +1,4 @@
-import { log } from '../core/log';
+import { log } from '../../core/log';
const PROXY_PREFIX = '/first-party/proxy';
diff --git a/crates/js/lib/src/ext/index.ts b/crates/js/lib/src/integrations/ext/index.ts
similarity index 100%
rename from crates/js/lib/src/ext/index.ts
rename to crates/js/lib/src/integrations/ext/index.ts
diff --git a/crates/js/lib/src/ext/prebidjs.ts b/crates/js/lib/src/integrations/ext/prebidjs.ts
similarity index 92%
rename from crates/js/lib/src/ext/prebidjs.ts
rename to crates/js/lib/src/integrations/ext/prebidjs.ts
index 4d6224c..00837f7 100644
--- a/crates/js/lib/src/ext/prebidjs.ts
+++ b/crates/js/lib/src/integrations/ext/prebidjs.ts
@@ -1,9 +1,9 @@
// Prebid.js compatibility shim: exposes tsjs API through the legacy pbjs global.
-import type { TsjsApi, HighestCpmBid, RequestAdsCallback, RequestAdsOptions } from '../core/types';
-import { log } from '../core/log';
-import { installQueue } from '../core/queue';
-import { getAllCodes, getAllUnits, firstSize } from '../core/registry';
-import { resolvePrebidWindow, PrebidWindow } from '../shared/globals';
+import type { TsjsApi, HighestCpmBid, RequestAdsCallback, RequestAdsOptions } from '../../core/types';
+import { log } from '../../core/log';
+import { installQueue } from '../../core/queue';
+import { getAllCodes, getAllUnits, firstSize } from '../../core/registry';
+import { resolvePrebidWindow, PrebidWindow } from '../../shared/globals';
type RequestBidsFunction = (
callbackOrOpts?: RequestAdsCallback | RequestAdsOptions,
opts?: RequestAdsOptions
diff --git a/crates/js/lib/src/ext/types.ts b/crates/js/lib/src/integrations/ext/types.ts
similarity index 100%
rename from crates/js/lib/src/ext/types.ts
rename to crates/js/lib/src/integrations/ext/types.ts
diff --git a/crates/js/lib/src/integrations/testlight.ts b/crates/js/lib/src/integrations/testlight/index.ts
similarity index 90%
rename from crates/js/lib/src/integrations/testlight.ts
rename to crates/js/lib/src/integrations/testlight/index.ts
index 6920b1e..3db00ce 100644
--- a/crates/js/lib/src/integrations/testlight.ts
+++ b/crates/js/lib/src/integrations/testlight/index.ts
@@ -1,7 +1,7 @@
-import type { TsjsApi } from '../core/types';
-import { installQueue } from '../core/queue';
-import { log } from '../core/log';
-import { resolvePrebidWindow, PrebidWindow } from '../shared/globals';
+import type { TsjsApi } from '../../core/types';
+import { installQueue } from '../../core/queue';
+import { log } from '../../core/log';
+import { resolvePrebidWindow, PrebidWindow } from '../../shared/globals';
type TestlightCallback = () => void;
diff --git a/crates/js/lib/test/creative/click.test.ts b/crates/js/lib/test/integrations/creative/click.test.ts
similarity index 100%
rename from crates/js/lib/test/creative/click.test.ts
rename to crates/js/lib/test/integrations/creative/click.test.ts
diff --git a/crates/js/lib/test/creative/helpers.ts b/crates/js/lib/test/integrations/creative/helpers.ts
similarity index 88%
rename from crates/js/lib/test/creative/helpers.ts
rename to crates/js/lib/test/integrations/creative/helpers.ts
index 885d4e6..176a5e5 100644
--- a/crates/js/lib/test/creative/helpers.ts
+++ b/crates/js/lib/test/integrations/creative/helpers.ts
@@ -17,7 +17,7 @@ export const MUTATED_CLICK = 'https://example.com/landing?bar=2';
export const PROXY_RESPONSE =
'/first-party/click?tsurl=https%3A%2F%2Fexample.com%2Flanding&bar=2&tstoken=newtoken';
-import type { TsCreativeConfig } from '../../src/shared/globals';
+import type { TsCreativeConfig } from '../../../src/shared/globals';
export async function importCreativeModule(config?: TsCreativeConfig): Promise {
const globalRef = globalThis as {
@@ -28,7 +28,7 @@ export async function importCreativeModule(config?: TsCreativeConfig): Promise = {
- core: {
- input: path.resolve(__dirname, 'src/core/index.ts'),
- fileName: 'tsjs-core.js',
- name: 'tsjs',
- },
- ext: {
- input: path.resolve(__dirname, 'src/ext/index.ts'),
- fileName: 'tsjs-ext.js',
- name: 'tsjs',
- extend: true,
- },
- creative: {
- input: path.resolve(__dirname, 'src/creative/index.ts'),
- fileName: 'tsjs-creative.js',
- name: 'tscreative',
- },
-};
+ // Read TSJS_MODULES env var: creative,ext,testlight
+ // - If not set or empty string: include all integrations (default behavior)
+ // - If set to comma-separated list: include only those integrations
+ const modulesEnv = process.env.TSJS_MODULES;
+ const requestedModules = (modulesEnv || '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
-function discoverIntegrationBundles(): Record {
- const integrationsDir = path.resolve(__dirname, 'src/integrations');
- if (!fs.existsSync(integrationsDir)) {
- return {};
- }
- const entries = fs.readdirSync(integrationsDir, { withFileTypes: true });
- return entries
- .filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
- .reduce>((acc, entry) => {
- const slug = entry.name.replace(/\.ts$/i, '');
- const key = `integration:${slug}`;
- acc[key] = {
- input: path.resolve(integrationsDir, entry.name),
- fileName: `tsjs-${slug}.js`,
- name: `tsjs_${slug.replace(/[^a-zA-Z0-9]/g, '_')}`,
- };
- return acc;
- }, {});
-}
+ // Discover integration modules: directories in src/integrations/ with index.ts
+ const integrationModules = fs.existsSync(integrationsDir)
+ ? fs
+ .readdirSync(integrationsDir)
+ .filter((name) => {
+ const fullPath = path.join(integrationsDir, name);
+ const stat = fs.statSync(fullPath);
+ return (
+ stat.isDirectory() &&
+ fs.existsSync(path.join(fullPath, 'index.ts'))
+ );
+ })
+ : [];
+
+ // Always include core first
+ const finalModules = ['core'];
-const INTEGRATION_BUNDLES = discoverIntegrationBundles();
-const BUNDLES: Record = { ...BASE_BUNDLES, ...INTEGRATION_BUNDLES };
+ // Add requested integrations based on TSJS_MODULES env var
+ if (requestedModules.length === 0) {
+ // TSJS_MODULES not set or empty: include all discovered integrations
+ finalModules.push(...integrationModules);
+ } else {
+ // TSJS_MODULES set to list: include only requested integrations (excluding 'core' as it's always added)
+ const requestedIntegrations = requestedModules.filter((m) => m !== 'core');
+ finalModules.push(
+ ...integrationModules.filter((m) => requestedIntegrations.includes(m))
+ );
+ }
-function resolveBundleKey(mode: string | undefined): string {
- const fromEnv = process.env.TSJS_BUNDLE;
- if (fromEnv && BUNDLES[fromEnv]) return fromEnv;
+ // Generate import statements
+ // Use namespace imports to capture all exports from each module
+ const importLines: string[] = [];
+ const exportEntries: string[] = [];
- const normalized = mode?.toLowerCase();
- if (normalized && BUNDLES[normalized]) return normalized;
+ for (const moduleName of finalModules) {
+ if (moduleName === 'core') {
+ importLines.push(`import * as core from './core/index';`);
+ } else {
+ importLines.push(
+ `import * as ${moduleName} from './integrations/${moduleName}/index';`
+ );
+ }
+ exportEntries.push(` ${moduleName},`);
+ }
+
+ // Write the generated file
+ const output = `// Auto-generated by vite-plugin-tsjs-module-discovery
+// DO NOT EDIT - This file is generated at build time based on TSJS_MODULES env var
+${importLines.join('\n')}
+
+export const modules = {
+${exportEntries.join('\n')}
+};
- if (normalized) {
- const integrationKey = `integration:${normalized}`;
- if (BUNDLES[integrationKey]) {
- return integrationKey;
- }
- }
+export type ModuleName = ${finalModules.map((m) => `'${m}'`).join(' | ')};
+`;
- return 'core';
+ const generatedFilePath = path.join(srcDir, 'generated-modules.ts');
+ fs.writeFileSync(generatedFilePath, output);
+
+ console.log('[tsjs-module-discovery] Generated src/generated-modules.ts');
+ console.log('[tsjs-module-discovery] Discovered integrations:', integrationModules);
+ console.log('[tsjs-module-discovery] Included modules:', finalModules);
+ },
+ };
}
-export default defineConfig(({ mode }) => {
- const bundleKey = resolveBundleKey(mode);
- const bundle = BUNDLES[bundleKey];
+export default defineConfig(() => {
const distDir = path.resolve(__dirname, '../dist');
const buildTimestamp = new Date().toISOString();
const banner = `// build: ${buildTimestamp}\n`;
@@ -85,32 +104,17 @@ export default defineConfig(({ mode }) => {
sourcemap: false,
minify: 'esbuild',
rollupOptions: {
- input: bundle.input,
+ input: path.resolve(__dirname, 'src/index.ts'),
output: {
format: 'iife',
dir: distDir,
- entryFileNames: bundle.fileName,
+ entryFileNames: 'tsjs-unified.js',
inlineDynamicImports: true,
- extend: bundle.extend ?? false,
- name: bundle.name,
+ extend: false,
+ name: 'tsjs',
},
},
},
- plugins: [createTimestampBannerPlugin(banner)],
+ plugins: [createModuleDiscoveryPlugin()],
};
});
-
-function createTimestampBannerPlugin(banner: string): Plugin {
- return {
- name: 'tsjs-build-timestamp-banner',
- generateBundle(_options, bundleOutput) {
- for (const file of Object.values(bundleOutput)) {
- if (file.type === 'chunk') {
- file.code = `${banner}${file.code}`;
- } else if (file.type === 'asset' && typeof file.source === 'string') {
- file.source = `${banner}${file.source}`;
- }
- }
- },
- };
-}
diff --git a/crates/js/src/bundle.rs b/crates/js/src/bundle.rs
index ce3f1de..8b7e0ff 100644
--- a/crates/js/src/bundle.rs
+++ b/crates/js/src/bundle.rs
@@ -1,22 +1,80 @@
use hex::encode;
use sha2::{Digest, Sha256};
-use std::collections::HashMap;
-use std::sync::OnceLock;
-include!(concat!(env!("OUT_DIR"), "/bundle_manifest.rs"));
+#[derive(Copy, Clone)]
+struct TsjsMeta {
+ filename: &'static str,
+ bundle: &'static str,
+}
+
+impl TsjsMeta {
+ const fn new(filename: &'static str, bundle: &'static str) -> Self {
+ Self { filename, bundle }
+ }
+}
+
+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,
+];
-static BUNDLE_MAP: OnceLock> = OnceLock::new();
+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()
+ }
+}
-fn bundles() -> &'static HashMap<&'static str, &'static str> {
- BUNDLE_MAP.get_or_init(|| BUNDLES.iter().copied().collect())
+pub fn bundle_hash(bundle: TsjsBundle) -> String {
+ hash_bundle(bundle.bundle())
}
pub fn bundle_for_filename(name: &str) -> Option<&'static str> {
- bundles().get(name).copied()
+ TsjsBundle::from_filename(name).map(|bundle| bundle.bundle())
}
-pub fn bundle_hash(filename: &str) -> Option {
- bundle_for_filename(filename).map(hash_bundle)
+pub fn bundle_hash_for_filename(name: &str) -> Option {
+ TsjsBundle::from_filename(name).map(|bundle| hash_bundle(bundle.bundle()))
}
fn hash_bundle(bundle: &'static str) -> String {
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index a4aa22d..7da69fc 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -1,3 +1,3 @@
pub mod bundle;
-pub use bundle::{bundle_for_filename, bundle_hash};
+pub use bundle::{bundle_for_filename, bundle_hash, bundle_hash_for_filename, TsjsBundle};