Skip to main content

mobench_sdk/builders/
web.rs

1//! Build automation for browser-hosted WebAssembly benchmarks.
2
3use super::common::{ToolCommand, get_cargo_target_dir, run_tool_command, validate_project_root};
4use crate::{BenchError, BuildProfile};
5use serde::Serialize;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9const WASM_TARGET: &str = "wasm32-unknown-unknown";
10const WEB_GLUE_NAME: &str = "mobench_web";
11const INDEX_HTML: &str = include_str!("../../templates/web/index.html");
12const RUNNER_JS: &str = include_str!("../../templates/web/runner.js");
13const WORKER_JS: &str = include_str!("../../templates/web/worker.js");
14
15/// Configuration for a generated web benchmark bundle.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct WebBuildConfig {
18    /// Cargo build profile used for the WebAssembly library.
19    pub profile: BuildProfile,
20}
21
22impl Default for WebBuildConfig {
23    fn default() -> Self {
24        Self {
25            profile: BuildProfile::Debug,
26        }
27    }
28}
29
30/// Paths produced by [`WebBuilder`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct WebBuildResult {
33    /// Root directory containing the complete static browser bundle.
34    pub bundle_dir: PathBuf,
35    /// Browser harness entry point.
36    pub index_html: PathBuf,
37    /// Main-thread harness controller.
38    pub runner_js: PathBuf,
39    /// JavaScript glue emitted by `wasm-bindgen`.
40    pub bindgen_js: PathBuf,
41    /// WebAssembly module emitted by `wasm-bindgen`.
42    pub wasm: PathBuf,
43    /// Machine-readable bundle manifest.
44    pub manifest: PathBuf,
45}
46
47/// Builds a benchmark crate for the browser and generates a static harness.
48pub struct WebBuilder {
49    project_root: PathBuf,
50    crate_name: String,
51    library_name: String,
52    crate_dir: Option<PathBuf>,
53    output_dir: PathBuf,
54    wasm_bindgen: PathBuf,
55    verbose: bool,
56    dry_run: bool,
57}
58
59impl WebBuilder {
60    /// Creates a web builder for one benchmark crate.
61    pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
62        let project_root = project_root.into();
63        let crate_name = crate_name.into();
64        Self {
65            output_dir: project_root.join("target/mobench"),
66            library_name: crate_name.replace('-', "_"),
67            project_root,
68            crate_name,
69            crate_dir: None,
70            wasm_bindgen: PathBuf::from("wasm-bindgen"),
71            verbose: false,
72            dry_run: false,
73        }
74    }
75
76    /// Overrides the Rust library artifact name.
77    pub fn library_name(mut self, library_name: impl Into<String>) -> Self {
78        self.library_name = library_name.into();
79        self
80    }
81
82    /// Overrides the benchmark crate directory.
83    pub fn crate_dir(mut self, crate_dir: impl Into<PathBuf>) -> Self {
84        self.crate_dir = Some(crate_dir.into());
85        self
86    }
87
88    /// Overrides the Mobench output root.
89    pub fn output_dir(mut self, output_dir: impl Into<PathBuf>) -> Self {
90        self.output_dir = output_dir.into();
91        self
92    }
93
94    /// Selects the `wasm-bindgen` executable or explicit path.
95    pub fn wasm_bindgen(mut self, wasm_bindgen: impl Into<PathBuf>) -> Self {
96        self.wasm_bindgen = wasm_bindgen.into();
97        self
98    }
99
100    /// Enables detailed build output.
101    pub fn verbose(mut self, verbose: bool) -> Self {
102        self.verbose = verbose;
103        self
104    }
105
106    /// Prints the deterministic build plan without running tools or writing files.
107    pub fn dry_run(mut self, dry_run: bool) -> Self {
108        self.dry_run = dry_run;
109        self
110    }
111
112    /// Compiles the crate and emits a complete browser bundle.
113    pub fn build(&self, config: &WebBuildConfig) -> Result<WebBuildResult, BenchError> {
114        validate_project_root(&self.project_root, &self.crate_name)?;
115        let crate_dir = self.resolve_crate_dir()?;
116        let bundle_dir = self.output_dir.join("web");
117        let worker_js = bundle_dir.join("worker.js");
118        let result = WebBuildResult {
119            index_html: bundle_dir.join("index.html"),
120            runner_js: bundle_dir.join("runner.js"),
121            bindgen_js: bundle_dir.join(format!("{WEB_GLUE_NAME}.js")),
122            wasm: bundle_dir.join(format!("{WEB_GLUE_NAME}_bg.wasm")),
123            manifest: bundle_dir.join("mobench-web.json"),
124            bundle_dir,
125        };
126
127        let profile = config.profile.as_str();
128        let target_dir = get_cargo_target_dir(&crate_dir)?;
129        let input_wasm = target_dir
130            .join(WASM_TARGET)
131            .join(profile)
132            .join(format!("{}.wasm", self.library_name));
133
134        if self.verbose || self.dry_run {
135            println!(
136                "{} cargo build --manifest-path {} --lib --target {WASM_TARGET}{}",
137                if self.dry_run { "[dry-run]" } else { "[web]" },
138                crate_dir.join("Cargo.toml").display(),
139                if config.profile == BuildProfile::Release {
140                    " --release"
141                } else {
142                    ""
143                }
144            );
145            println!(
146                "{} {} {} --target web --out-dir {} --out-name {WEB_GLUE_NAME}",
147                if self.dry_run { "[dry-run]" } else { "[web]" },
148                self.wasm_bindgen.display(),
149                input_wasm.display(),
150                result.bundle_dir.display()
151            );
152        }
153        if self.dry_run {
154            return Ok(result);
155        }
156
157        let mut cargo = ToolCommand::path_search("cargo");
158        cargo
159            .arg("build")
160            .arg("--manifest-path")
161            .arg(crate_dir.join("Cargo.toml"))
162            .arg("--lib")
163            .arg("--target")
164            .arg(WASM_TARGET);
165        if config.profile == BuildProfile::Release {
166            cargo.arg("--release");
167        }
168        run_tool_command(cargo, "web benchmark Cargo build")?;
169        if !input_wasm.is_file() {
170            return Err(BenchError::Build(format!(
171                "WebAssembly artifact was not produced at {}.\n\n\
172                 Ensure the benchmark crate has:\n\
173                 [lib]\n\
174                 crate-type = [\"lib\", \"cdylib\"]",
175                input_wasm.display()
176            )));
177        }
178
179        fs::create_dir_all(&result.bundle_dir).map_err(|error| {
180            BenchError::Build(format!(
181                "Failed to create web bundle directory {}: {error}",
182                result.bundle_dir.display()
183            ))
184        })?;
185        let mut bindgen = ToolCommand::explicit(&self.wasm_bindgen)?;
186        bindgen
187            .arg(&input_wasm)
188            .arg("--target")
189            .arg("web")
190            .arg("--out-dir")
191            .arg(&result.bundle_dir)
192            .arg("--out-name")
193            .arg(WEB_GLUE_NAME)
194            .arg("--no-typescript");
195        run_tool_command(bindgen, "wasm-bindgen web glue generation")?;
196
197        write_bundle_file(&result.index_html, INDEX_HTML)?;
198        write_bundle_file(&result.runner_js, RUNNER_JS)?;
199        write_bundle_file(&worker_js, WORKER_JS)?;
200        let manifest = WebBundleManifest {
201            schema: "mobench.web-bundle.v1",
202            crate_name: &self.crate_name,
203            library_name: &self.library_name,
204            profile,
205            target: WASM_TARGET,
206            entrypoint: "index.html",
207            runner: "runner.js",
208            worker: "worker.js",
209            javascript: "mobench_web.js",
210            wasm: "mobench_web_bg.wasm",
211        };
212        let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|error| {
213            BenchError::Build(format!("Failed to serialize web bundle manifest: {error}"))
214        })?;
215        write_bundle_file(&result.manifest, &manifest_json)?;
216
217        for required in [
218            &result.index_html,
219            &result.runner_js,
220            &worker_js,
221            &result.bindgen_js,
222            &result.wasm,
223            &result.manifest,
224        ] {
225            if !required.is_file() {
226                return Err(BenchError::Build(format!(
227                    "Web bundle is incomplete; expected {}",
228                    required.display()
229                )));
230            }
231        }
232
233        Ok(result)
234    }
235
236    fn resolve_crate_dir(&self) -> Result<PathBuf, BenchError> {
237        if let Some(crate_dir) = &self.crate_dir {
238            if crate_dir.join("Cargo.toml").is_file() {
239                return Ok(crate_dir.clone());
240            }
241            return Err(BenchError::Build(format!(
242                "Benchmark crate Cargo.toml not found at {}",
243                crate_dir.join("Cargo.toml").display()
244            )));
245        }
246
247        for candidate in [
248            self.project_root.clone(),
249            self.project_root.join("bench-mobile"),
250            self.project_root.join("crates").join(&self.crate_name),
251            self.project_root.join(&self.crate_name),
252        ] {
253            if candidate.join("Cargo.toml").is_file() {
254                return Ok(candidate);
255            }
256        }
257        Err(BenchError::Build(format!(
258            "Could not locate benchmark crate '{}' under {}",
259            self.crate_name,
260            self.project_root.display()
261        )))
262    }
263}
264
265#[derive(Serialize)]
266struct WebBundleManifest<'a> {
267    schema: &'static str,
268    crate_name: &'a str,
269    library_name: &'a str,
270    profile: &'a str,
271    target: &'static str,
272    entrypoint: &'static str,
273    runner: &'static str,
274    worker: &'static str,
275    javascript: &'static str,
276    wasm: &'static str,
277}
278
279fn write_bundle_file(path: &Path, contents: &str) -> Result<(), BenchError> {
280    fs::write(path, contents)
281        .map_err(|error| BenchError::Build(format!("Failed to write {}: {error}", path.display())))
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use std::fs;
288
289    #[test]
290    fn dry_run_returns_deterministic_bundle_paths() {
291        let temp = tempfile::tempdir().expect("tempdir");
292        fs::write(
293            temp.path().join("Cargo.toml"),
294            "[package]\nname = \"demo-bench\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
295        )
296        .expect("manifest");
297        fs::write(temp.path().join("lib.rs"), "").expect("library");
298        let output = temp.path().join("out");
299
300        let result = WebBuilder::new(temp.path(), "demo-bench")
301            .output_dir(&output)
302            .dry_run(true)
303            .build(&WebBuildConfig {
304                profile: BuildProfile::Release,
305            })
306            .expect("dry-run web build");
307
308        assert_eq!(result.bundle_dir, output.join("web"));
309        assert_eq!(result.index_html, output.join("web/index.html"));
310        assert_eq!(result.wasm, output.join("web/mobench_web_bg.wasm"));
311        assert!(!result.bundle_dir.exists());
312    }
313
314    #[test]
315    fn templates_expose_worker_backed_window_contract() {
316        assert!(INDEX_HTML.contains("runner.js"));
317        assert!(RUNNER_JS.contains("window.mobench"));
318        assert!(RUNNER_JS.contains("new Worker"));
319        assert!(!RUNNER_JS.contains("runBenchmarkJson("));
320        assert!(WORKER_JS.contains("runBenchmarkJson"));
321        assert!(WORKER_JS.contains("JSON.parse"));
322    }
323}