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    fn find_node_modules(root_dir: &Path) -> Option<PathBuf> {
102        let mut current = root_dir.to_path_buf();
103        loop {
104            let node_modules = current.join("node_modules");
105            if node_modules.exists() && node_modules.is_dir() {
106                return Some(node_modules);
107            }
108            if !current.pop() {
109                return None;
110            }
111        }
112    }
113
114    pub fn compile_wasm(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
115        let source_dir = self.source_path.parent().ok_or_else(|| {
116            JavascriptWasmCompilerError::FsError("Cannot determine source directory".to_string())
117        })?;
118
119        if !SourceFingerprint::needs_rebuild(
120            &self.cache_dir,
121            source_dir,
122            &self.output_wasm,
123            &["js", "ts", "toml"],
124            &["node_modules", "dist"],
125        ) {
126            return Ok(self.output_wasm.clone());
127        }
128
129        let wit_path = self.get_wit_path()?;
130        let sdk_path = self.get_sdk_path()?;
131
132        let source_for_import = if self.source_path.extension().is_some_and(|ext| ext == "ts") {
133            self.transpile_typescript()?
134        } else {
135            self.source_path.clone()
136        };
137
138        let wrapper_path = self.cache_dir.join("_capsule_boot.js");
139        let bundled_path = self.cache_dir.join("_capsule_bundled.js");
140
141        let import_path = Self::normalize_path_for_import(
142            &source_for_import
143                .canonicalize()
144                .unwrap_or_else(|_| source_for_import.to_path_buf()),
145        );
146
147        let sdk_path_str = Self::normalize_path_for_import(&sdk_path);
148
149        let wrapper_content = format!(
150            r#"// Auto-generated bootloader for Capsule
151import * as hostApi from 'capsule:host/api';
152import * as fsTypes from 'wasi:filesystem/types@0.2.0';
153import * as fsPreopens from 'wasi:filesystem/preopens@0.2.0';
154import * as environment from 'wasi:cli/environment@0.2.0';
155globalThis['capsule:host/api'] = hostApi;
156globalThis['wasi:filesystem/types'] = fsTypes;
157globalThis['wasi:filesystem/preopens'] = fsPreopens;
158globalThis['wasi:cli/environment'] = environment;
159import '{}';
160import {{ exports }} from '{}/dist/app.js';
161export const taskRunner = exports;
162            "#,
163            import_path, sdk_path_str
164        );
165
166        fs::write(&wrapper_path, wrapper_content)?;
167
168        let wrapper_path_normalized = Self::normalize_path_for_command(&wrapper_path);
169        let bundled_path_normalized = Self::normalize_path_for_command(&bundled_path);
170        let wit_path_normalized = Self::normalize_path_for_command(&wit_path);
171        let sdk_path_normalized = Self::normalize_path_for_command(&sdk_path);
172        let output_wasm_normalized = Self::normalize_path_for_command(&self.output_wasm);
173
174        let project_node_modules = Self::find_node_modules(source_dir)
175            .map(|p| Self::normalize_path_for_command(&p))
176            .unwrap_or_else(|| Self::normalize_path_for_command(&source_dir.join("node_modules")));
177        let path_browserify_path = project_node_modules.join("path-browserify");
178        let os_polyfill_path = sdk_path_normalized.join("dist/polyfills/os.js");
179        let process_polyfill_path = sdk_path_normalized.join("dist/polyfills/process.js");
180        let url_polyfill_path = sdk_path_normalized.join("dist/polyfills/url.js");
181        let buffer_polyfill_path = sdk_path_normalized.join("dist/polyfills/buffer.js");
182        let events_polyfill_path = sdk_path_normalized.join("dist/polyfills/events.js");
183        let stream_polyfill_path = sdk_path_normalized.join("dist/polyfills/stream.js");
184        let fs_polyfill_path = sdk_path_normalized.join("dist/polyfills/fs.js");
185
186        let esbuild_output = Self::npx_command()
187            .arg("esbuild")
188            .arg(&wrapper_path_normalized)
189            .arg("--bundle")
190            .arg("--format=esm")
191            .arg("--platform=neutral")
192            .arg("--main-fields=main,module")
193            .arg("--external:capsule:host/api")
194            .arg("--external:wasi:filesystem/*")
195            .arg("--external:wasi:cli/*")
196            .arg(format!("--inject:{}", process_polyfill_path.display()))
197            .arg(format!("--inject:{}", buffer_polyfill_path.display()))
198            .arg(format!("--inject:{}", events_polyfill_path.display()))
199            .arg(format!("--alias:path={}", path_browserify_path.display()))
200            .arg(format!(
201                "--alias:node:path={}",
202                path_browserify_path.display()
203            ))
204            .arg(format!("--alias:os={}", os_polyfill_path.display()))
205            .arg(format!("--alias:node:os={}", os_polyfill_path.display()))
206            .arg(format!(
207                "--alias:process={}",
208                process_polyfill_path.display()
209            ))
210            .arg(format!(
211                "--alias:node:process={}",
212                process_polyfill_path.display()
213            ))
214            .arg(format!("--alias:url={}", url_polyfill_path.display()))
215            .arg(format!("--alias:node:url={}", url_polyfill_path.display()))
216            .arg(format!(
217                "--alias:buffer={}",
218                project_node_modules.join("buffer").display()
219            ))
220            .arg(format!(
221                "--alias:node:buffer={}",
222                project_node_modules.join("buffer").display()
223            ))
224            .arg(format!(
225                "--alias:events={}",
226                project_node_modules.join("events").display()
227            ))
228            .arg(format!(
229                "--alias:node:events={}",
230                project_node_modules.join("events").display()
231            ))
232            .arg(format!("--alias:stream={}", stream_polyfill_path.display()))
233            .arg(format!(
234                "--alias:node:stream={}",
235                stream_polyfill_path.display()
236            ))
237            .arg(format!("--alias:fs={}", fs_polyfill_path.display()))
238            .arg(format!("--alias:node:fs={}", fs_polyfill_path.display()))
239            .arg(format!(
240                "--alias:fs/promises={}",
241                sdk_path_normalized
242                    .join("dist/polyfills/fs-promises.js")
243                    .display()
244            ))
245            .arg(format!(
246                "--alias:node:fs/promises={}",
247                sdk_path_normalized
248                    .join("dist/polyfills/fs-promises.js")
249                    .display()
250            ))
251            .arg(format!(
252                "--alias:stream/web={}",
253                sdk_path_normalized
254                    .join("dist/polyfills/stream-web.js")
255                    .display()
256            ))
257            .arg(format!(
258                "--alias:node:stream/web={}",
259                sdk_path_normalized
260                    .join("dist/polyfills/stream-web.js")
261                    .display()
262            ))
263            .arg(format!("--outfile={}", bundled_path_normalized.display()))
264            .current_dir(&sdk_path_normalized)
265            .stdout(Stdio::piped())
266            .stderr(Stdio::piped())
267            .output()?;
268
269        if !esbuild_output.status.success() {
270            return Err(JavascriptWasmCompilerError::CompileFailed(format!(
271                "Bundling failed: {}",
272                String::from_utf8_lossy(&esbuild_output.stderr).trim()
273            )));
274        }
275
276        let jco_output = Self::npx_command()
277            .arg("jco")
278            .arg("componentize")
279            .arg(&bundled_path_normalized)
280            .arg("--wit")
281            .arg(&wit_path_normalized)
282            .arg("--world-name")
283            .arg("capsule-agent")
284            .arg("--enable")
285            .arg("http")
286            .arg("-o")
287            .arg(&output_wasm_normalized)
288            .current_dir(&sdk_path_normalized)
289            .stdout(Stdio::piped())
290            .stderr(Stdio::piped())
291            .output()?;
292
293        if !jco_output.status.success() {
294            return Err(JavascriptWasmCompilerError::CompileFailed(format!(
295                "Component creation failed: {}",
296                String::from_utf8_lossy(&jco_output.stderr).trim()
297            )));
298        }
299
300        let _ = SourceFingerprint::update_after_build(
301            &self.cache_dir,
302            source_dir,
303            &["js", "ts", "toml"],
304            &["node_modules", "dist"],
305        );
306
307        Ok(self.output_wasm.clone())
308    }
309
310    fn get_wit_path(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
311        let wit_dir = self.cache_dir.join("wit");
312
313        if !wit_dir.join("capsule.wit").exists() {
314            WitManager::import_wit_deps(&wit_dir)?;
315        }
316
317        Ok(wit_dir)
318    }
319
320    fn get_sdk_path(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
321        if let Some(source_dir) = self.source_path.parent()
322            && let Some(node_modules) = Self::find_node_modules(source_dir)
323        {
324            let sdk_path = node_modules.join("@capsule-run/sdk");
325            if sdk_path.exists() {
326                return Ok(sdk_path);
327            }
328        }
329
330        if let Ok(exe_path) = std::env::current_exe()
331            && let Some(project_root) = exe_path
332                .parent()
333                .and_then(|p| p.parent())
334                .and_then(|p| p.parent())
335                .and_then(|p| p.parent())
336                .and_then(|p| p.parent())
337        {
338            let sdk_path = project_root.join("crates/capsule-sdk/javascript");
339            if sdk_path.exists() {
340                return Ok(sdk_path);
341            }
342        }
343
344        Err(JavascriptWasmCompilerError::FsError(
345            "Could not find JavaScript SDK.".to_string(),
346        ))
347    }
348
349    fn transpile_typescript(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
350        let output_path = self.cache_dir.join(
351            self.source_path
352                .file_stem()
353                .and_then(|s| s.to_str())
354                .map(|s| format!("{}.js", s))
355                .ok_or_else(|| {
356                    JavascriptWasmCompilerError::FsError("Invalid source filename".to_string())
357                })?,
358        );
359
360        let output = Self::npx_command()
361            .arg("tsc")
362            .arg(&self.source_path)
363            .arg("--outDir")
364            .arg(&self.cache_dir)
365            .arg("--module")
366            .arg("esnext")
367            .arg("--target")
368            .arg("esnext")
369            .arg("--moduleResolution")
370            .arg("bundler")
371            .arg("--esModuleInterop")
372            .arg("--skipLibCheck")
373            .stdout(Stdio::piped())
374            .stderr(Stdio::piped())
375            .output()?;
376
377        if !output.status.success() {
378            let stdout = String::from_utf8_lossy(&output.stdout);
379            let stderr = String::from_utf8_lossy(&output.stderr);
380
381            return Err(JavascriptWasmCompilerError::CompileFailed(format!(
382                "TypeScript compilation failed: {}{}",
383                stderr.trim(),
384                if !stdout.is_empty() {
385                    format!("\nstdout: {}", stdout.trim())
386                } else {
387                    String::new()
388                }
389            )));
390        }
391
392        if !output_path.exists() {
393            return Err(JavascriptWasmCompilerError::FsError(format!(
394                "TypeScript transpilation did not produce expected output: {}",
395                output_path.display()
396            )));
397        }
398
399        Ok(output_path)
400    }
401
402    pub fn introspect_task_registry(&self) -> Option<HashMap<String, serde_json::Value>> {
403        let source_dir = self.source_path.parent()?;
404        scanner::scan_js_tasks(source_dir)
405    }
406}