From 7dd4169e19dee153cf62e52f6c93c052760fa141 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Fri, 25 Sep 2026 21:45:42 +0530 Subject: [PATCH 1/3] Build tsjs bundles into a private OUT_DIR and validate the set The build script shared crates/trusted-server-js/dist with every other cargo build and with manual npm runs, so overlapping builds could embed a partial or empty bundle set and still exit 0. build-all.mjs now accepts --out-dir, and build.rs builds into OUT_DIR/tsjs-dist and fails unless it holds exactly core plus every lib/src/integrations//index.ts, each non-empty. Silent reuse of dist is gone: TSJS_SKIP_BUILD and a missing npm now fail with instructions, and TSJS_PREBUILT_DIR embeds prebuilt bundles after the same check. Stale node_modules fails instead of reinstalling, npm ci on a missing node_modules is serialized with a file lock, TSJS_TEST failures fail the build, rerun-if-env-changed covers every variable read, and rerun-if-changed is narrowed to the sources. Fixes #1200 Signed-off-by: dhruv8sh --- crates/trusted-server-js/README.md | 7 +- crates/trusted-server-js/build.rs | 307 ++++++++++--------- crates/trusted-server-js/build/bundle_set.rs | 228 ++++++++++++++ crates/trusted-server-js/lib/.gitignore | 3 + crates/trusted-server-js/lib/build-all.mjs | 25 +- crates/trusted-server-js/src/lib.rs | 9 + docs/guide/creative-processing.md | 2 +- docs/guide/error-reference.md | 29 +- 8 files changed, 460 insertions(+), 150 deletions(-) create mode 100644 crates/trusted-server-js/build/bundle_set.rs diff --git a/crates/trusted-server-js/README.md b/crates/trusted-server-js/README.md index 7b83072fa..254ab6f55 100644 --- a/crates/trusted-server-js/README.md +++ b/crates/trusted-server-js/README.md @@ -5,8 +5,11 @@ Rust wrapper and TypeScript build for Trusted Server's browser runtime. The build script discovers the checked TypeScript entries, invokes the Node build, and embeds the resulting IIFE bundles in Rust. The Rust API returns individual bundles, deterministic concatenations, and content hashes used by -adapter responses. Browser behavior lives under `lib/src`; generated `dist` -files are build outputs, not edited sources. The external Prebid.js artifact is +adapter responses. Browser behavior lives under `lib/src`. Cargo builds the +bundles into a private directory under its `OUT_DIR` and fails if any expected +bundle is missing or empty; the `dist` directory is written only by a manual +`npm run build` (for browser tests) and is never embedded unless you pass it +explicitly through `TSJS_PREBUILT_DIR`. The external Prebid.js artifact is built separately and is not the deferred Trusted Server Prebid shim. Run the JavaScript checks from `crates/trusted-server-js/lib`: diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index ba6cd88f2..7f5abe893 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -4,146 +4,218 @@ reason = "build script failures should stop Cargo with a clear diagnostic" )] -use std::cmp::Ordering; +#[path = "build/bundle_set.rs"] +mod bundle_set; + use std::env; use std::fmt::Write as _; -use std::fs; +use std::fs::{self, File}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; -use build_print::{info, warn}; +use build_print::info; use sha2::{Digest as _, Sha256}; -fn main() { - // Rebuild if TS sources change (belt-and-suspenders): enumerate every file under lib/ - println!("cargo:rerun-if-changed=lib"); - watch_dir_recursively(Path::new("lib")); +use crate::bundle_set::{bundle_file_name, check_bundle_set, expected_module_ids, scan_bundle_dir}; - // Allow opt-out or force via env - let skip = env::var("TSJS_SKIP_BUILD").is_ok_and(|value| value == "1"); +const PREBUILT_DIR_VAR: &str = "TSJS_PREBUILT_DIR"; +const SKIP_BUILD_VAR: &str = "TSJS_SKIP_BUILD"; +const TEST_VAR: &str = "TSJS_TEST"; + +fn main() { + // Cargo scans directories recursively, so these cover every TS source. + for path in [ + "lib/src", + "lib/build-all.mjs", + "lib/package.json", + "lib/package-lock.json", + "lib/tsconfig.json", + "lib/node_modules/.package-lock.json", + ] { + println!("cargo:rerun-if-changed={path}"); + } + for var in [PREBUILT_DIR_VAR, SKIP_BUILD_VAR, TEST_VAR] { + println!("cargo:rerun-if-env-changed={var}"); + } let crate_dir = PathBuf::from( env::var("CARGO_MANIFEST_DIR").expect("should set CARGO_MANIFEST_DIR for build script"), ); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("should set OUT_DIR for build script")); let ts_dir = crate_dir.join("lib"); - let dist_dir = crate_dir.join("dist"); + // Private to this build script run: concurrent builds never share it. + let bundle_dir = out_dir.join("tsjs-dist"); - // Ensure dist exists - fs::create_dir_all(&dist_dir).expect("should create dist directory"); + let expected = expected_module_ids(&ts_dir.join("src")) + .unwrap_or_else(|err| panic!("tsjs: failed to discover modules: {err}")); - // Only try to build if we have a library project - if !ts_dir.join("package.json").exists() { - // No TS project; rely on prebuilt dist if present - return; + if let Some(prebuilt_dir) = env::var_os(PREBUILT_DIR_VAR).map(PathBuf::from) { + println!("cargo:rerun-if-changed={}", prebuilt_dir.display()); + info!( + "tsjs: Using prebuilt bundles from {}", + prebuilt_dir.display() + ); + validate_bundle_dir(&expected, &prebuilt_dir); + copy_prebuilt_bundles(&expected, &prebuilt_dir, &bundle_dir); + } else { + build_bundles(&ts_dir, &bundle_dir); } - // If Node/npm is absent, keep going if dist exists - let npm = which::which("npm").ok(); - if npm.is_none() { - warn!("tsjs: npm not found; will use existing dist if available"); - } + validate_bundle_dir(&expected, &bundle_dir); + info!( + "tsjs: Embedding {} module files: {:?}", + expected.len(), + expected + ); - // Install deps if node_modules missing - if !skip - && let Some(npm_path) = npm.as_deref() - && !ts_dir.join("node_modules").exists() - { - let status = Command::new(npm_path) - .arg("ci") - .current_dir(&ts_dir) - .status(); - if !status.as_ref().is_ok_and(ExitStatus::success) { - warn!("tsjs: npm ci failed; using existing dist if available"); - } - } + write_module_table(&expected, &bundle_dir, &out_dir); +} - // Run tests if requested - if !skip - && env::var("TSJS_TEST").is_ok_and(|value| value == "1") - && let Some(npm_path) = npm.as_deref() - { - Command::new(npm_path) - .args(["run", "test", "--", "--run"]) - .current_dir(&ts_dir) - .status() - .expect("should run requested TSJS tests"); - } +fn build_bundles(ts_dir: &Path, bundle_dir: &Path) { + let how_to_prebuild = format!( + "To embed prebuilt bundles instead, run `npm run build` in {} and set \ + {PREBUILT_DIR_VAR} to the directory holding the tsjs-*.js files (by default {}).", + ts_dir.display(), + ts_dir.with_file_name("dist").display() + ); + + assert!( + env::var_os(SKIP_BUILD_VAR).is_none(), + "tsjs: {SKIP_BUILD_VAR} is no longer supported because it embedded whatever \ + dist/ held. {how_to_prebuild}" + ); + assert!( + ts_dir.join("package.json").is_file(), + "tsjs: {} not found. {how_to_prebuild}", + ts_dir.join("package.json").display() + ); + let npm = which::which("npm").unwrap_or_else(|_| { + panic!("tsjs: npm not found on PATH; Node.js is required to build the tsjs bundles. {how_to_prebuild}") + }); - // Build all module files - if !skip && let Some(npm_path) = npm.as_deref() { - info!("tsjs: Building per-module bundles"); + install_dependencies_if_missing(&npm, ts_dir); + ensure_dependencies_fresh(ts_dir); - let status = Command::new(npm_path) - .args(["run", "build"]) - .current_dir(&ts_dir) + if env::var(TEST_VAR).is_ok_and(|value| value == "1") { + let status = Command::new(&npm) + .args(["run", "test", "--", "--run"]) + .current_dir(ts_dir) .status(); assert!( status.as_ref().is_ok_and(ExitStatus::success), - "tsjs: npm run build failed - refusing to use stale bundles" + "tsjs: {TEST_VAR}=1 requested the tsjs tests and they failed" ); } - // Discover all tsjs-*.js files in dist/ - let mut modules: Vec<(String, String)> = Vec::new(); // (id, filename) - if let Ok(entries) = fs::read_dir(&dist_dir) { - for entry in entries.flatten() { - let filename = entry.file_name().to_string_lossy().to_string(); - if let Some(id) = filename - .strip_prefix("tsjs-") - .and_then(|stem| stem.strip_suffix(".js")) - { - modules.push((id.to_owned(), filename)); - } - } - } + info!( + "tsjs: Building per-module bundles into {}", + bundle_dir.display() + ); + let status = Command::new(&npm) + .args(["run", "build", "--", "--out-dir"]) + .arg(bundle_dir) + .current_dir(ts_dir) + .status(); + assert!( + status.as_ref().is_ok_and(ExitStatus::success), + "tsjs: npm run build failed - refusing to embed incomplete bundles" + ); +} - // Sort alphabetically but ensure "core" is always first - modules.sort_by(|left, right| { - if left.0 == "core" { - Ordering::Less - } else if right.0 == "core" { - Ordering::Greater - } else { - left.0.cmp(&right.0) - } - }); +/// Run `npm ci` when `node_modules` is absent, serialized across build scripts. +fn install_dependencies_if_missing(npm: &Path, ts_dir: &Path) { + let node_modules = ts_dir.join("node_modules"); + + // Two build scripts must not run `npm ci` in the same directory at once. + // Check only while holding the lock: `npm ci` creates node_modules seconds + // before it finishes, so an unlocked check can see a partial install. The + // lock is released when `lock_file` drops. + let lock_path = ts_dir.join(".tsjs-npm-ci.lock"); + let lock_file = File::create(&lock_path) + .unwrap_or_else(|err| panic!("tsjs: failed to create {}: {err}", lock_path.display())); + lock_file + .lock() + .unwrap_or_else(|err| panic!("tsjs: failed to lock {}: {err}", lock_path.display())); + + if node_modules.exists() { + return; + } + info!("tsjs: node_modules missing; running npm ci"); + let status = Command::new(npm).arg("ci").current_dir(ts_dir).status(); assert!( - !modules.is_empty(), - "tsjs: no tsjs-*.js files found in {}. Ensure `npm run build` succeeds.", - dist_dir.display() + status.as_ref().is_ok_and(ExitStatus::success), + "tsjs: npm ci failed in {}", + ts_dir.display() ); +} - info!( - "tsjs: Discovered {} module files: {:?}", - modules.len(), - modules - .iter() - .map(|(id, _)| id.as_str()) - .collect::>() +/// Fail when `node_modules` is older than `package-lock.json`. +/// +/// Uses npm's own freshness signal, the hidden lockfile it writes on install. +/// Reinstalling automatically would delete `node_modules` under any other +/// build script that is running, so this only reports the problem. +fn ensure_dependencies_fresh(ts_dir: &Path) { + let lockfile = ts_dir.join("package-lock.json"); + let hidden_lockfile = ts_dir.join("node_modules").join(".package-lock.json"); + let stale_message = format!( + "tsjs: node_modules is out of date with package-lock.json; run `npm ci` in {}", + ts_dir.display() ); - // Copy each module file to OUT_DIR - for (_, filename) in &modules { - copy_bundle(filename, true, &dist_dir, &out_dir); + let Ok(lockfile_modified) = fs::metadata(&lockfile).and_then(|meta| meta.modified()) else { + return; + }; + let hidden_modified = fs::metadata(&hidden_lockfile) + .and_then(|meta| meta.modified()) + .unwrap_or_else(|_| panic!("{stale_message} ({} missing)", hidden_lockfile.display())); + assert!(hidden_modified >= lockfile_modified, "{stale_message}"); +} + +fn validate_bundle_dir(expected: &[String], dir: &Path) { + let found = scan_bundle_dir(dir).unwrap_or_else(|err| panic!("tsjs: {err}")); + if let Err(err) = check_bundle_set(expected, &found) { + panic!("tsjs: invalid bundle set in {}: {err}", dir.display()); + } +} + +fn copy_prebuilt_bundles(expected: &[String], prebuilt_dir: &Path, bundle_dir: &Path) { + if bundle_dir.exists() { + fs::remove_dir_all(bundle_dir) + .unwrap_or_else(|err| panic!("tsjs: failed to clean {}: {err}", bundle_dir.display())); + } + fs::create_dir_all(bundle_dir) + .unwrap_or_else(|err| panic!("tsjs: failed to create {}: {err}", bundle_dir.display())); + for id in expected { + let file_name = bundle_file_name(id); + let source = prebuilt_dir.join(&file_name); + let target = bundle_dir.join(&file_name); + fs::copy(&source, &target).unwrap_or_else(|err| { + panic!( + "tsjs: failed to copy {} to {}: {err}", + source.display(), + target.display() + ) + }); } +} - // Generate tsjs_modules.rs with include_str!() for each module +fn write_module_table(expected: &[String], bundle_dir: &Path, out_dir: &Path) { let mut codegen = String::new(); codegen.push_str("// Auto-generated by build.rs - DO NOT EDIT\n\n"); writeln!( codegen, "pub(crate) const TSJS_MODULES: [TsjsModuleMeta; {}] = [", - modules.len() + expected.len() ) .expect("should write generated module header"); - for (id, filename) in &modules { - let sha256 = bundle_sha256(&out_dir.join(filename)); + for id in expected { + let filename = bundle_file_name(id); + let sha256 = bundle_sha256(&bundle_dir.join(&filename)); writeln!( codegen, - " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\n sha256: \"{sha256}\",\n }},\n" + " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/tsjs-dist/{filename}\")),\n id: \"{id}\",\n sha256: \"{sha256}\",\n }},\n" ) .expect("should write generated module entry"); } @@ -166,56 +238,9 @@ fn main() { fn bundle_sha256(path: &Path) -> String { let content = fs::read(path).unwrap_or_else(|err| { panic!( - "tsjs: failed to read copied bundle {} for hashing: {err}", + "tsjs: failed to read bundle {} for hashing: {err}", path.display() ); }); hex::encode(Sha256::digest(&content)) } - -fn copy_bundle(filename: &str, required: bool, dist_dir: &Path, out_dir: &Path) { - let source = dist_dir.join(filename); - let target = out_dir.join(filename); - - if source.exists() { - if let Err(err) = fs::copy(&source, &target) { - assert!( - !required, - "tsjs: failed to copy {} to {}: {err}", - source.display(), - target.display() - ); - } - return; - } - - assert!( - !required, - "tsjs: bundle {filename} not found: {}. Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", - source.display() - ); - - fs::write(&target, "").expect("should write optional empty bundle placeholder"); -} - -fn watch_dir_recursively(root: &Path) { - if !root.exists() { - return; - } - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - let Ok(read) = fs::read_dir(&dir) else { - continue; - }; - for entry in read.flatten() { - let path = entry.path(); - // Always ask Cargo to rerun if this path changes - if let Some(path_str) = path.to_str() { - println!("cargo:rerun-if-changed={path_str}"); - } - if path.is_dir() { - stack.push(path); - } - } - } -} diff --git a/crates/trusted-server-js/build/bundle_set.rs b/crates/trusted-server-js/build/bundle_set.rs new file mode 100644 index 000000000..ec9b52ab3 --- /dev/null +++ b/crates/trusted-server-js/build/bundle_set.rs @@ -0,0 +1,228 @@ +//! Expected tsjs bundle set: discovery and validation. +//! +//! Shared by `build.rs` and the crate's unit tests through `#[path]`, so it +//! depends only on `std`. + +use std::fs; +use std::path::Path; + +/// Module ID of the core bundle, which is always expected and ordered first. +pub(crate) const CORE_MODULE_ID: &str = "core"; + +/// Return the bundle file name for a module ID, for example `tsjs-core.js`. +pub(crate) fn bundle_file_name(id: &str) -> String { + format!("tsjs-{id}.js") +} + +/// Return the module IDs `build-all.mjs` builds from `lib_src`. +/// +/// Mirrors the discovery in `build-all.mjs`: `core` plus every +/// `integrations//index.ts`. `core` comes first, integrations follow in +/// alphabetical order. +/// +/// # Errors +/// +/// Returns a message when the integrations directory cannot be read. +pub(crate) fn expected_module_ids(lib_src: &Path) -> Result, String> { + let integrations_dir = lib_src.join("integrations"); + let mut integrations = Vec::new(); + if integrations_dir.exists() { + let entries = fs::read_dir(&integrations_dir).map_err(|err| { + format!( + "failed to read integrations directory {}: {err}", + integrations_dir.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|err| { + format!( + "failed to read an entry in {}: {err}", + integrations_dir.display() + ) + })?; + let path = entry.path(); + if path.is_dir() && path.join("index.ts").is_file() { + integrations.push(entry.file_name().to_string_lossy().into_owned()); + } + } + } + integrations.sort(); + + let mut ids = Vec::with_capacity(integrations.len() + 1); + ids.push(CORE_MODULE_ID.to_owned()); + ids.extend(integrations); + Ok(ids) +} + +/// List the `tsjs-.js` bundles in `dir` as `(id, byte length)` pairs. +/// +/// # Errors +/// +/// Returns a message when the directory or a bundle's metadata cannot be read. +pub(crate) fn scan_bundle_dir(dir: &Path) -> Result, String> { + let entries = fs::read_dir(dir) + .map_err(|err| format!("failed to read bundle directory {}: {err}", dir.display()))?; + let mut found = Vec::new(); + for entry in entries { + let entry = + entry.map_err(|err| format!("failed to read an entry in {}: {err}", dir.display()))?; + let file_name = entry.file_name().to_string_lossy().into_owned(); + let Some(id) = file_name + .strip_prefix("tsjs-") + .and_then(|stem| stem.strip_suffix(".js")) + else { + continue; + }; + let len = entry + .metadata() + .map_err(|err| format!("failed to read metadata for {file_name}: {err}"))? + .len(); + found.push((id.to_owned(), len)); + } + Ok(found) +} + +/// Check that `found` holds exactly the `expected` modules, each non-empty. +/// +/// # Errors +/// +/// Returns a message listing every missing, empty and unexpected bundle. +pub(crate) fn check_bundle_set(expected: &[String], found: &[(String, u64)]) -> Result<(), String> { + let mut missing = Vec::new(); + let mut empty = Vec::new(); + for id in expected { + match found.iter().find(|(found_id, _)| found_id == id) { + None => missing.push(id.as_str()), + Some((_, 0)) => empty.push(id.as_str()), + Some(_) => {} + } + } + + let mut unexpected = found + .iter() + .map(|(id, _)| id.as_str()) + .filter(|id| !expected.iter().any(|expected_id| expected_id == id)) + .collect::>(); + unexpected.sort_unstable(); + + let mut problems = Vec::new(); + if !missing.is_empty() { + problems.push(format!("missing bundles: {missing:?}")); + } + if !empty.is_empty() { + problems.push(format!("empty bundles: {empty:?}")); + } + if !unexpected.is_empty() { + problems.push(format!("unexpected bundles: {unexpected:?}")); + } + + if problems.is_empty() { + Ok(()) + } else { + Err(format!( + "{} (expected {} modules: {expected:?})", + problems.join("; "), + expected.len() + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn expected() -> Vec { + ["core", "gpt", "prebid"] + .into_iter() + .map(str::to_owned) + .collect() + } + + fn found(entries: &[(&str, u64)]) -> Vec<(String, u64)> { + entries + .iter() + .map(|(id, len)| ((*id).to_owned(), *len)) + .collect() + } + + #[test] + fn accepts_complete_non_empty_set() { + let found = found(&[("prebid", 30), ("core", 10), ("gpt", 20)]); + + let result = check_bundle_set(&expected(), &found); + + assert!(result.is_ok(), "should accept a complete set: {result:?}"); + } + + #[test] + fn rejects_set_missing_one_bundle() { + let found = found(&[("core", 10), ("prebid", 30)]); + + let err = check_bundle_set(&expected(), &found).expect_err("should reject missing bundle"); + + assert!( + err.contains(r#"missing bundles: ["gpt"]"#), + "should name the missing bundle: {err}" + ); + } + + #[test] + fn rejects_set_missing_core() { + let found = found(&[("gpt", 20), ("prebid", 30)]); + + let err = check_bundle_set(&expected(), &found).expect_err("should reject missing core"); + + assert!( + err.contains(r#"missing bundles: ["core"]"#), + "should name core as missing: {err}" + ); + } + + #[test] + fn rejects_set_with_one_empty_bundle() { + let found = found(&[("core", 10), ("gpt", 0), ("prebid", 30)]); + + let err = check_bundle_set(&expected(), &found).expect_err("should reject empty bundle"); + + assert!( + err.contains(r#"empty bundles: ["gpt"]"#), + "should name the empty bundle: {err}" + ); + } + + #[test] + fn rejects_set_with_unexpected_bundle() { + let found = found(&[("core", 10), ("gpt", 20), ("prebid", 30), ("removed", 5)]); + + let err = + check_bundle_set(&expected(), &found).expect_err("should reject unexpected bundle"); + + assert!( + err.contains(r#"unexpected bundles: ["removed"]"#), + "should name the unexpected bundle: {err}" + ); + } + + #[test] + fn reports_every_problem_at_once() { + let found = found(&[("core", 0), ("extra", 1)]); + + let err = check_bundle_set(&expected(), &found).expect_err("should reject bad set"); + + assert!( + err.contains(r#"missing bundles: ["gpt", "prebid"]"#) + && err.contains(r#"empty bundles: ["core"]"#) + && err.contains(r#"unexpected bundles: ["extra"]"#), + "should list missing, empty and unexpected bundles together: {err}" + ); + } + + #[test] + fn bundle_file_name_matches_build_all_output() { + assert_eq!( + bundle_file_name("gpt_diagnostics"), + "tsjs-gpt_diagnostics.js", + "should match the tsjs-.js name build-all.mjs writes" + ); + } +} diff --git a/crates/trusted-server-js/lib/.gitignore b/crates/trusted-server-js/lib/.gitignore index 1fd14250a..56c5a23dd 100644 --- a/crates/trusted-server-js/lib/.gitignore +++ b/crates/trusted-server-js/lib/.gitignore @@ -1,3 +1,6 @@ # Auto-generated files src/generated-modules.ts src/integrations/prebid/.external-generated-* + +# Serializes `npm ci` across concurrent cargo build scripts +.tsjs-npm-ci.lock diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 2bfee01b1..717ddef38 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -4,7 +4,7 @@ * Builds each integration as a separate IIFE file so the Rust server can * concatenate only the enabled modules at runtime. * - * Output (in ../dist/): + * Output (in ../dist/, or the directory passed as `--out-dir `): * tsjs-core.js — core API (always included) * tsjs-.js — one per discovered integration * @@ -12,6 +12,10 @@ * is never bundled into tsjs. Use build-prebid-external.mjs to generate the * pure Prebid.js external bundle (core + adapters + user ID modules) that the * shim requires at runtime via integrations.prebid.external_bundle_url. + * + * The Rust build script passes `--out-dir` with a private directory under + * Cargo's OUT_DIR, so concurrent cargo builds never share (or clean) the same + * output directory. */ import fs from 'node:fs'; @@ -21,10 +25,23 @@ import { build } from 'vite'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const srcDir = path.resolve(__dirname, 'src'); -const distDir = path.resolve(__dirname, '..', 'dist'); +const distDir = resolveOutDir(process.argv.slice(2)); const integrationsDir = path.join(srcDir, 'integrations'); -// Clean dist directory +/** Resolve `--out-dir ` (relative to the cwd), defaulting to ../dist. */ +function resolveOutDir(args) { + const flagIndex = args.indexOf('--out-dir'); + if (flagIndex === -1) { + return path.resolve(__dirname, '..', 'dist'); + } + const value = args[flagIndex + 1]; + if (!value || value.startsWith('--')) { + throw new Error('[build-all] --out-dir requires a directory argument'); + } + return path.resolve(value); +} + +// Clean the output directory fs.rmSync(distDir, { recursive: true, force: true }); fs.mkdirSync(distDir, { recursive: true }); @@ -39,7 +56,7 @@ const integrationModules = fs.existsSync(integrationsDir) ); }) .sort() - : []; + : []; console.log('[build-all] Discovered integrations:', integrationModules); diff --git a/crates/trusted-server-js/src/lib.rs b/crates/trusted-server-js/src/lib.rs index 2c816b154..8acb8ba2b 100644 --- a/crates/trusted-server-js/src/lib.rs +++ b/crates/trusted-server-js/src/lib.rs @@ -5,6 +5,15 @@ pub mod bundle; +// Build-script helpers, compiled here only so their unit tests run with the crate's. +#[cfg(test)] +#[allow( + dead_code, + reason = "only the pure helpers are exercised by unit tests" +)] +#[path = "../build/bundle_set.rs"] +mod bundle_set; + pub use bundle::{ all_module_ids, concatenate_modules, concatenated_hash, module_bundle, single_module_hash, }; diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 10dcb62d5..5edf64c9e 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -812,7 +812,7 @@ accept an arbitrary asset name from an integration registration. ### Bundle Types -Each integration is built as a separate IIFE at compile time (`crates/trusted-server-js/dist/`): +Each integration is built as a separate IIFE at compile time (into Cargo's `OUT_DIR`; `npm run build` writes the same files to `crates/trusted-server-js/dist/`): - `tsjs-core.js` — Core API (always included) - `tsjs-creative.js` — Creative click-guard and tracking diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index 3b3fe65ed..5de2dea57 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -612,12 +612,37 @@ npm run build npm run lint ``` -4. Skip TSJS build temporarily: +4. Embed bundles you built yourself instead of building them during `cargo build`: ```bash -TSJS_SKIP_BUILD=1 cargo build +cd crates/trusted-server-js/lib && npm run build && cd - +TSJS_PREBUILT_DIR="$PWD/crates/trusted-server-js/dist" cargo build ``` +`TSJS_PREBUILT_DIR` is checked the same way as a normal build: every expected +bundle must be present and non-empty. `TSJS_SKIP_BUILD` is no longer supported. + +--- + +### TSJS build script errors + +The `trusted-server-js` build script builds the bundles into a private +directory under Cargo's `OUT_DIR` and fails rather than embed an incomplete +set. It never reads `crates/trusted-server-js/dist`, which only `npm run build` +writes. + +| Error message contains | Cause and fix | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `tsjs: npm not found on PATH` | Node.js is not installed or not on `PATH`. Install Node.js, or set `TSJS_PREBUILT_DIR` as shown above. | +| `tsjs: TSJS_SKIP_BUILD is no longer supported` | Unset `TSJS_SKIP_BUILD` and use `TSJS_PREBUILT_DIR` instead. | +| `tsjs: node_modules is out of date with package-lock.json` | Dependencies changed, for example after switching branches. Run `npm ci` in `crates/trusted-server-js/lib`. | +| `tsjs: npm ci failed` | `node_modules` was missing and the automatic install failed. Run `npm ci` in `crates/trusted-server-js/lib` to see the error. | +| `tsjs: invalid bundle set ... missing bundles` / `empty bundles` | The Node build or the `TSJS_PREBUILT_DIR` directory did not produce one `tsjs-.js` per `core` and `lib/src/integrations//index.ts`. | +| `tsjs: invalid bundle set ... unexpected bundles` | `TSJS_PREBUILT_DIR` holds bundles from a different source tree. Rebuild it with `npm run build`. | + +Set `TSJS_TEST=1` to run the TypeScript tests before the build; a failing test +fails the build. + --- ### Version mismatch error From a68898010d996ca5eeb6b5718575dd69a9a8c525 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 28 Sep 2026 14:43:22 +0530 Subject: [PATCH 2/3] Watch node_modules lockfile and tsjs tests only when used Cargo reruns a build script on every invocation while a watched path is missing, so always watching lib/node_modules/.package-lock.json made every build rerun under TSJS_PREBUILT_DIR without node_modules. Watch it only on the npm build path, after the freshness check has confirmed it exists. Watch lib/test and lib/vitest.config.ts when TSJS_TEST=1 so edited tests rerun, and note in the error reference that the timestamp-based freshness check also fires when a checkout rewrites an unchanged lockfile. Signed-off-by: dhruv8sh --- crates/trusted-server-js/build.rs | 10 +++++++++- docs/guide/error-reference.md | 16 ++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 7f5abe893..8d23b7d78 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -30,7 +30,6 @@ fn main() { "lib/package.json", "lib/package-lock.json", "lib/tsconfig.json", - "lib/node_modules/.package-lock.json", ] { println!("cargo:rerun-if-changed={path}"); } @@ -95,8 +94,17 @@ fn build_bundles(ts_dir: &Path, bundle_dir: &Path) { install_dependencies_if_missing(&npm, ts_dir); ensure_dependencies_fresh(ts_dir); + // Watched only once it exists: Cargo reruns a build script on every + // invocation while a watched path is missing. + println!( + "cargo:rerun-if-changed={}", + ts_dir.join("node_modules/.package-lock.json").display() + ); if env::var(TEST_VAR).is_ok_and(|value| value == "1") { + for path in ["test", "vitest.config.ts"] { + println!("cargo:rerun-if-changed={}", ts_dir.join(path).display()); + } let status = Command::new(&npm) .args(["run", "test", "--", "--run"]) .current_dir(ts_dir) diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index 5de2dea57..f2cff3112 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -631,14 +631,14 @@ directory under Cargo's `OUT_DIR` and fails rather than embed an incomplete set. It never reads `crates/trusted-server-js/dist`, which only `npm run build` writes. -| Error message contains | Cause and fix | -| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `tsjs: npm not found on PATH` | Node.js is not installed or not on `PATH`. Install Node.js, or set `TSJS_PREBUILT_DIR` as shown above. | -| `tsjs: TSJS_SKIP_BUILD is no longer supported` | Unset `TSJS_SKIP_BUILD` and use `TSJS_PREBUILT_DIR` instead. | -| `tsjs: node_modules is out of date with package-lock.json` | Dependencies changed, for example after switching branches. Run `npm ci` in `crates/trusted-server-js/lib`. | -| `tsjs: npm ci failed` | `node_modules` was missing and the automatic install failed. Run `npm ci` in `crates/trusted-server-js/lib` to see the error. | -| `tsjs: invalid bundle set ... missing bundles` / `empty bundles` | The Node build or the `TSJS_PREBUILT_DIR` directory did not produce one `tsjs-.js` per `core` and `lib/src/integrations//index.ts`. | -| `tsjs: invalid bundle set ... unexpected bundles` | `TSJS_PREBUILT_DIR` holds bundles from a different source tree. Rebuild it with `npm run build`. | +| Error message contains | Cause and fix | +| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tsjs: npm not found on PATH` | Node.js is not installed or not on `PATH`. Install Node.js, or set `TSJS_PREBUILT_DIR` as shown above. | +| `tsjs: TSJS_SKIP_BUILD is no longer supported` | Unset `TSJS_SKIP_BUILD` and use `TSJS_PREBUILT_DIR` instead. | +| `tsjs: node_modules is out of date with package-lock.json` | Dependencies changed, for example after switching branches. Run `npm ci` in `crates/trusted-server-js/lib`. The check compares file times, so a checkout that rewrites an unchanged lockfile also triggers it; `npm ci` clears it. | +| `tsjs: npm ci failed` | `node_modules` was missing and the automatic install failed. Run `npm ci` in `crates/trusted-server-js/lib` to see the error. | +| `tsjs: invalid bundle set ... missing bundles` / `empty bundles` | The Node build or the `TSJS_PREBUILT_DIR` directory did not produce one `tsjs-.js` per `core` and `lib/src/integrations//index.ts`. | +| `tsjs: invalid bundle set ... unexpected bundles` | `TSJS_PREBUILT_DIR` holds bundles from a different source tree. Rebuild it with `npm run build`. | Set `TSJS_TEST=1` to run the TypeScript tests before the build; a failing test fails the build. From 53e2f58bf885ee64e98d77576a2b4a14533863b3 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 28 Sep 2026 15:37:25 +0530 Subject: [PATCH 3/3] Point the template cache test at the new tsjs bundle path The build script now writes bundles to OUT_DIR/tsjs-dist, so the GPT module lookup in template-cache-local-test.sh must search out/tsjs-dist/tsjs-gpt.js. Signed-off-by: dhruv8sh --- scripts/template-cache-local-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/template-cache-local-test.sh b/scripts/template-cache-local-test.sh index 790cbd8b0..1e7541564 100755 --- a/scripts/template-cache-local-test.sh +++ b/scripts/template-cache-local-test.sh @@ -560,7 +560,7 @@ else GPT_BUNDLE="$candidate" fi done < <(find "$REPO_ROOT/target/wasm32-wasip1/debug/build" \ - -path '*/out/tsjs-gpt.js' -type f -print0) + -path '*/out/tsjs-dist/tsjs-gpt.js' -type f -print0) if [ -z "$GPT_BUNDLE" ] || [ ! -s "$GPT_BUNDLE" ]; then bad "the generated GPT module cannot be found" else