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};