mcp_preview/
wasm_builder.rs1use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use tokio::sync::RwLock;
10use tracing::{info, warn};
11
12#[derive(Debug, Clone)]
14pub enum BuildStatus {
15 NotBuilt,
17 Building,
19 Ready(PathBuf),
21 Failed(String),
23}
24
25impl BuildStatus {
26 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 fn is_ready(&self) -> bool {
38 matches!(self, Self::Ready(_))
39 }
40}
41
42pub struct WasmBuilder {
48 source_dir: PathBuf,
50 cache_dir: PathBuf,
52 build_status: RwLock<BuildStatus>,
54}
55
56impl WasmBuilder {
57 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 pub async fn ensure_built(&self) -> Result<PathBuf, String> {
83 {
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 {
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 self.build().await
101 }
102
103 pub async fn build(&self) -> Result<PathBuf, String> {
105 {
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 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 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 pub async fn status(&self) -> String {
193 self.build_status.read().await.as_str()
194 }
195
196 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 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 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 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
239pub 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}