Skip to main content

mcp_preview/
wasm_builder.rs

1//! WASM build orchestration and artifact caching
2//!
3//! Automates `wasm-pack build` for the WASM client, caches output artifacts,
4//! and provides status tracking for the preview server's WASM bridge mode.
5
6use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use tokio::sync::RwLock;
10use tracing::{info, warn};
11
12/// Current state of the WASM build pipeline
13#[derive(Debug, Clone)]
14pub enum BuildStatus {
15    /// No build has been attempted yet
16    NotBuilt,
17    /// A build is currently in progress
18    Building,
19    /// Build succeeded; contains the path to the `pkg/` output directory
20    Ready(PathBuf),
21    /// Build failed with the given error message
22    Failed(String),
23}
24
25impl BuildStatus {
26    /// Return a human-readable status string suitable for JSON responses.
27    pub fn as_str(&self) -> String {
28        match self {
29            Self::NotBuilt => "not_built".to_string(),
30            Self::Building => "building".to_string(),
31            Self::Ready(_) => "ready".to_string(),
32            Self::Failed(msg) => format!("failed: {msg}"),
33        }
34    }
35
36    /// Whether the build is in the `Ready` state.
37    fn is_ready(&self) -> bool {
38        matches!(self, Self::Ready(_))
39    }
40}
41
42/// Orchestrates `wasm-pack build` and caches the resulting artifacts.
43///
44/// The builder tracks build status behind a `tokio::sync::RwLock` so
45/// multiple HTTP handlers can safely query status or trigger builds
46/// concurrently.
47pub struct WasmBuilder {
48    /// Directory containing the WASM client source (e.g. `examples/wasm-client/`)
49    source_dir: PathBuf,
50    /// Directory where build artifacts are cached (e.g. `target/wasm-bridge/`)
51    cache_dir: PathBuf,
52    /// Current build status, protected for async-safe access
53    build_status: RwLock<BuildStatus>,
54}
55
56impl WasmBuilder {
57    /// Create a new builder.
58    ///
59    /// If cached artifacts from a previous build already exist on disk,
60    /// the initial status is set to `Ready`; otherwise `NotBuilt`.
61    pub fn new(source_dir: PathBuf, cache_dir: PathBuf) -> Self {
62        let pkg_dir = cache_dir.join("pkg");
63        let initial_status = if Self::artifacts_exist(&pkg_dir) {
64            info!("Found cached WASM artifacts at {}", pkg_dir.display());
65            BuildStatus::Ready(pkg_dir)
66        } else {
67            BuildStatus::NotBuilt
68        };
69
70        Self {
71            source_dir,
72            cache_dir,
73            build_status: RwLock::new(initial_status),
74        }
75    }
76
77    /// Ensure the WASM client has been built and return the artifact directory.
78    ///
79    /// - If `Ready`, returns the cached path immediately.
80    /// - If `NotBuilt` or `Failed`, triggers a new build.
81    /// - If `Building`, polls until the build completes (short sleep loop).
82    pub async fn ensure_built(&self) -> Result<PathBuf, String> {
83        // Fast path: already built
84        {
85            let status = self.build_status.read().await;
86            if let BuildStatus::Ready(ref path) = *status {
87                return Ok(path.clone());
88            }
89        }
90
91        // Check if a build is in progress
92        {
93            let status = self.build_status.read().await;
94            if matches!(*status, BuildStatus::Building) {
95                return self.wait_for_build().await;
96            }
97        }
98
99        // Trigger a new build
100        self.build().await
101    }
102
103    /// Trigger a `wasm-pack build` and return the artifact directory on success.
104    pub async fn build(&self) -> Result<PathBuf, String> {
105        // Acquire write lock and set Building status
106        {
107            let mut status = self.build_status.write().await;
108            if status.is_ready() {
109                if let BuildStatus::Ready(ref path) = *status {
110                    return Ok(path.clone());
111                }
112            }
113            *status = BuildStatus::Building;
114        }
115
116        // Check that wasm-pack is available
117        if !self.wasm_pack_available().await {
118            let msg = "WASM mode requires wasm-pack. Install with: \
119                       cargo install wasm-pack && rustup target add wasm32-unknown-unknown"
120                .to_string();
121            let mut status = self.build_status.write().await;
122            *status = BuildStatus::Failed(msg.clone());
123            return Err(msg);
124        }
125
126        // Ensure source directory exists
127        if !self.source_dir.exists() {
128            let msg = format!(
129                "WASM client source not found at {}",
130                self.source_dir.display()
131            );
132            let mut status = self.build_status.write().await;
133            *status = BuildStatus::Failed(msg.clone());
134            return Err(msg);
135        }
136
137        let pkg_dir = self.cache_dir.join("pkg");
138        info!(
139            "Starting wasm-pack build: source={}, out={}",
140            self.source_dir.display(),
141            pkg_dir.display()
142        );
143
144        let result = tokio::process::Command::new("wasm-pack")
145            .arg("build")
146            .arg("--target")
147            .arg("web")
148            .arg("--out-name")
149            .arg("mcp_wasm_client")
150            .arg("--no-opt")
151            .arg("--out-dir")
152            .arg(&pkg_dir)
153            .current_dir(&self.source_dir)
154            .env("CARGO_PROFILE_RELEASE_LTO", "false")
155            .stdout(Stdio::piped())
156            .stderr(Stdio::piped())
157            .output()
158            .await;
159
160        match result {
161            Ok(output) if output.status.success() => {
162                info!("wasm-pack build succeeded");
163                let mut status = self.build_status.write().await;
164                *status = BuildStatus::Ready(pkg_dir.clone());
165                Ok(pkg_dir)
166            },
167            Ok(output) => {
168                let stderr = String::from_utf8_lossy(&output.stderr);
169                let stdout = String::from_utf8_lossy(&output.stdout);
170                let msg = format!(
171                    "wasm-pack build failed (exit code {:?}):\n{}\n{}",
172                    output.status.code(),
173                    stderr,
174                    stdout
175                );
176                warn!("{}", msg);
177                let mut status = self.build_status.write().await;
178                *status = BuildStatus::Failed(msg.clone());
179                Err(msg)
180            },
181            Err(e) => {
182                let msg = format!("Failed to spawn wasm-pack: {e}");
183                warn!("{}", msg);
184                let mut status = self.build_status.write().await;
185                *status = BuildStatus::Failed(msg.clone());
186                Err(msg)
187            },
188        }
189    }
190
191    /// Return the current build status as a human-readable string.
192    pub async fn status(&self) -> String {
193        self.build_status.read().await.as_str()
194    }
195
196    /// Return the path to the artifact directory if the build is ready.
197    pub async fn artifact_dir(&self) -> Option<PathBuf> {
198        let status = self.build_status.read().await;
199        match *status {
200            BuildStatus::Ready(ref path) => Some(path.clone()),
201            _ => None,
202        }
203    }
204
205    /// Check whether `wasm-pack` is installed and accessible.
206    async fn wasm_pack_available(&self) -> bool {
207        tokio::process::Command::new("wasm-pack")
208            .arg("--version")
209            .stdout(Stdio::null())
210            .stderr(Stdio::null())
211            .status()
212            .await
213            .is_ok_and(|s| s.success())
214    }
215
216    /// Poll until the build transitions out of the `Building` state.
217    async fn wait_for_build(&self) -> Result<PathBuf, String> {
218        loop {
219            tokio::time::sleep(std::time::Duration::from_millis(250)).await;
220            let status = self.build_status.read().await;
221            match &*status {
222                BuildStatus::Ready(path) => return Ok(path.clone()),
223                BuildStatus::Failed(msg) => return Err(msg.clone()),
224                BuildStatus::Building => continue,
225                BuildStatus::NotBuilt => {
226                    return Err("Build was reset while waiting".to_string());
227                },
228            }
229        }
230    }
231
232    /// Check whether the expected WASM artifacts exist on disk.
233    fn artifacts_exist(pkg_dir: &Path) -> bool {
234        pkg_dir.join("mcp_wasm_client.js").exists()
235            && pkg_dir.join("mcp_wasm_client_bg.wasm").exists()
236    }
237}
238
239/// Locate the workspace root by walking up from `start_dir` and looking
240/// for a `Cargo.toml` that contains `[workspace]`.
241pub fn find_workspace_root(start_dir: &Path) -> Option<PathBuf> {
242    let mut dir = start_dir.to_path_buf();
243    loop {
244        let cargo_toml = dir.join("Cargo.toml");
245        if cargo_toml.exists() {
246            if let Ok(contents) = std::fs::read_to_string(&cargo_toml) {
247                if contents.contains("[workspace]") {
248                    return Some(dir);
249                }
250            }
251        }
252        if !dir.pop() {
253            return None;
254        }
255    }
256}