capsule_core/wasm/compiler/
javascript.rs1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5use super::CAPSULE_WIT;
6
7#[derive(Debug)]
8pub enum JavascriptWasmCompilerError {
9 FsError(String),
10 CommandFailed(String),
11 CompileFailed(String),
12}
13
14impl std::fmt::Display for JavascriptWasmCompilerError {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 JavascriptWasmCompilerError::FsError(msg) => write!(f, "Filesystem error > {}", msg),
18 JavascriptWasmCompilerError::CommandFailed(msg) => {
19 write!(f, "Command failed > {}", msg)
20 }
21 JavascriptWasmCompilerError::CompileFailed(msg) => {
22 write!(f, "Compilation failed > {}", msg)
23 }
24 }
25 }
26}
27
28impl From<std::io::Error> for JavascriptWasmCompilerError {
29 fn from(err: std::io::Error) -> Self {
30 JavascriptWasmCompilerError::FsError(err.to_string())
31 }
32}
33
34impl From<std::time::SystemTimeError> for JavascriptWasmCompilerError {
35 fn from(err: std::time::SystemTimeError) -> Self {
36 JavascriptWasmCompilerError::FsError(err.to_string())
37 }
38}
39
40pub struct JavascriptWasmCompiler {
41 pub source_path: PathBuf,
42 pub cache_dir: PathBuf,
43 pub output_wasm: PathBuf,
44}
45
46impl JavascriptWasmCompiler {
47 fn normalize_path_for_command(path: &Path) -> PathBuf {
48 #[cfg(windows)]
49 {
50 let path_str = path.to_string_lossy();
51 if path_str.starts_with(r"\\?\") {
52 return PathBuf::from(&path_str[4..]);
53 }
54 }
55 path.to_path_buf()
56 }
57
58 pub fn new(source_path: &Path) -> Result<Self, JavascriptWasmCompilerError> {
59 let source_path = source_path.canonicalize().map_err(|e| {
60 JavascriptWasmCompilerError::FsError(format!("Cannot resolve source path: {}", e))
61 })?;
62
63 let cache_dir = source_path
64 .parent()
65 .ok_or_else(|| JavascriptWasmCompilerError::FsError("Invalid source path".to_string()))?
66 .join(".capsule");
67
68 fs::create_dir_all(&cache_dir)?;
69
70 let output_wasm = cache_dir.join("agent.wasm");
71
72 Ok(Self {
73 source_path,
74 cache_dir,
75 output_wasm,
76 })
77 }
78
79 pub fn compile_wasm(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
80 if self.needs_rebuild(&self.source_path, &self.output_wasm)? {
81 let wit_path = self.get_wit_path()?;
82
83 let sdk_path = self.get_sdk_path()?;
84
85 let source_for_import = if self.source_path.extension().is_some_and(|ext| ext == "ts") {
86 self.transpile_typescript()?
87 } else {
88 self.source_path.clone()
89 };
90
91 let wrapper_path = self.cache_dir.join("_capsule_boot.js");
92 let bundled_path = self.cache_dir.join("_capsule_bundled.js");
93
94 let import_path = source_for_import
95 .canonicalize()
96 .unwrap_or_else(|_| source_for_import.to_path_buf())
97 .display()
98 .to_string();
99
100 let sdk_path_str = sdk_path.to_str().ok_or_else(|| {
101 JavascriptWasmCompilerError::FsError("Invalid SDK path".to_string())
102 })?;
103
104 let wrapper_content = format!(
105 r#"// Auto-generated bootloader for Capsule
106 import * as hostApi from 'capsule:host/api';
107 globalThis['capsule:host/api'] = hostApi;
108 import '{}';
109 import {{ exports }} from '{}/dist/app.js';
110 export const taskRunner = exports;
111 "#,
112 import_path, sdk_path_str
113 );
114
115 fs::write(&wrapper_path, wrapper_content)?;
116
117 let wrapper_path_normalized = Self::normalize_path_for_command(&wrapper_path);
118 let bundled_path_normalized = Self::normalize_path_for_command(&bundled_path);
119 let wit_path_normalized = Self::normalize_path_for_command(&wit_path);
120 let sdk_path_normalized = Self::normalize_path_for_command(&sdk_path);
121 let output_wasm_normalized = Self::normalize_path_for_command(&self.output_wasm);
122
123 let esbuild_output = Command::new("npx")
124 .arg("esbuild")
125 .arg(&wrapper_path_normalized)
126 .arg("--bundle")
127 .arg("--format=esm")
128 .arg("--platform=neutral")
129 .arg("--external:capsule:host/api")
130 .arg(format!("--outfile={}", bundled_path_normalized.display()))
131 .current_dir(&sdk_path_normalized)
132 .stdout(Stdio::piped())
133 .stderr(Stdio::piped())
134 .output()?;
135
136 if !esbuild_output.status.success() {
137 return Err(JavascriptWasmCompilerError::CompileFailed(format!(
138 "Bundling failed: {}",
139 String::from_utf8_lossy(&esbuild_output.stderr).trim()
140 )));
141 }
142
143 let jco_output = Command::new("npx")
144 .arg("jco")
145 .arg("componentize")
146 .arg(&bundled_path_normalized)
147 .arg("--wit")
148 .arg(&wit_path_normalized)
149 .arg("--world-name")
150 .arg("capsule-agent")
151 .arg("--enable")
152 .arg("http")
153 .arg("-o")
154 .arg(&output_wasm_normalized)
155 .current_dir(&sdk_path_normalized)
156 .stdout(Stdio::piped())
157 .stderr(Stdio::piped())
158 .output()?;
159
160 if !jco_output.status.success() {
161 return Err(JavascriptWasmCompilerError::CompileFailed(format!(
162 "Component creation failed: {}",
163 String::from_utf8_lossy(&jco_output.stderr).trim()
164 )));
165 }
166 }
167
168 Ok(self.output_wasm.clone())
169 }
170
171 fn get_wit_path(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
172 if let Ok(path) = std::env::var("CAPSULE_WIT_PATH") {
173 let wit_path = PathBuf::from(path);
174 if wit_path.exists() {
175 return Ok(wit_path);
176 }
177 }
178
179 let wit_dir = self.cache_dir.join("wit");
180 let wit_file = wit_dir.join("capsule.wit");
181
182 if !wit_file.exists() {
183 fs::create_dir_all(&wit_dir)?;
184 fs::write(&wit_file, CAPSULE_WIT)?;
185 }
186
187 Ok(wit_dir)
188 }
189
190 fn get_sdk_path(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
191 if let Ok(path) = std::env::var("CAPSULE_JS_SDK_PATH") {
192 let sdk_path = PathBuf::from(path);
193 if sdk_path.exists() {
194 return Ok(sdk_path);
195 }
196 }
197
198 if let Some(source_dir) = self.source_path.parent() {
199 let node_modules_sdk = source_dir.join("node_modules/@capsule-run/sdk");
200 if node_modules_sdk.exists() {
201 return Ok(node_modules_sdk);
202 }
203 }
204
205 if let Ok(exe_path) = std::env::current_exe()
206 && let Some(project_root) = exe_path
207 .parent()
208 .and_then(|p| p.parent())
209 .and_then(|p| p.parent())
210 .and_then(|p| p.parent())
211 .and_then(|p| p.parent())
212 {
213 let sdk_path = project_root.join("crates/capsule-sdk/javascript");
214 if sdk_path.exists() {
215 return Ok(sdk_path);
216 }
217 }
218
219 Err(JavascriptWasmCompilerError::FsError(
220 "Could not find JavaScript SDK. Set CAPSULE_JS_SDK_PATH environment variable or run 'npm link @capsule-run/sdk' in your project directory.".to_string()
221 ))
222 }
223
224 fn needs_rebuild(
225 &self,
226 source: &Path,
227 output: &Path,
228 ) -> Result<bool, JavascriptWasmCompilerError> {
229 if !output.exists() {
230 return Ok(true);
231 }
232
233 let output_time = fs::metadata(output).and_then(|m| m.modified())?;
234
235 let source_time = fs::metadata(source).and_then(|m| m.modified())?;
236 if source_time > output_time {
237 return Ok(true);
238 }
239
240 if let Some(source_dir) = source.parent()
241 && Self::check_dir_modified(source_dir, source, output_time)?
242 {
243 return Ok(true);
244 }
245
246 Ok(false)
247 }
248
249 fn check_dir_modified(
250 dir: &Path,
251 source: &Path,
252 wasm_time: std::time::SystemTime,
253 ) -> Result<bool, JavascriptWasmCompilerError> {
254 if let Ok(entries) = fs::read_dir(dir) {
255 for entry in entries.flatten() {
256 let path = entry.path();
257
258 if path.is_dir() {
259 let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
260 if dir_name.starts_with('.') || dir_name == "node_modules" {
261 continue;
262 }
263
264 if Self::check_dir_modified(&path, source, wasm_time)? {
265 return Ok(true);
266 }
267 } else if let Some(ext) = path.extension()
268 && (ext == "js" || ext == "ts")
269 && path != source
270 && let Ok(metadata) = fs::metadata(&path)
271 && let Ok(modified) = metadata.modified()
272 && modified > wasm_time
273 {
274 return Ok(true);
275 }
276 }
277 }
278
279 Ok(false)
280 }
281
282 fn transpile_typescript(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
283 let output_path = self.cache_dir.join(
284 self.source_path
285 .file_stem()
286 .and_then(|s| s.to_str())
287 .map(|s| format!("{}.js", s))
288 .ok_or_else(|| {
289 JavascriptWasmCompilerError::FsError("Invalid source filename".to_string())
290 })?,
291 );
292
293 let output = Command::new("npx")
294 .arg("tsc")
295 .arg(&self.source_path)
296 .arg("--outDir")
297 .arg(&self.cache_dir)
298 .arg("--module")
299 .arg("esnext")
300 .arg("--target")
301 .arg("esnext")
302 .arg("--moduleResolution")
303 .arg("node")
304 .arg("--esModuleInterop")
305 .stdout(Stdio::piped())
306 .stderr(Stdio::piped())
307 .output()?;
308
309 if !output.status.success() {
310 let stdout = String::from_utf8_lossy(&output.stdout);
311 let stderr = String::from_utf8_lossy(&output.stderr);
312 eprintln!("TypeScript compilation failed!");
313 eprintln!("stdout: {}", stdout);
314 eprintln!("stderr: {}", stderr);
315 return Err(JavascriptWasmCompilerError::CompileFailed(format!(
316 "TypeScript compilation failed: {}{}",
317 stderr.trim(),
318 if !stdout.is_empty() {
319 format!("\nstdout: {}", stdout.trim())
320 } else {
321 String::new()
322 }
323 )));
324 }
325
326 if !output_path.exists() {
327 return Err(JavascriptWasmCompilerError::FsError(format!(
328 "TypeScript transpilation did not produce expected output: {}",
329 output_path.display()
330 )));
331 }
332
333 Ok(output_path)
334 }
335}