Skip to main content

capsule_core/wasm/compiler/
javascript.rs

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