capsule_core/wasm/compiler/
javascript.rs

1use 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}