Skip to main content

mobench_sdk/builders/
web.rs

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