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::cache::generate_wasm_filename;
9use crate::wasm::utilities::introspection::javascript::extract_js_task_configs;
10use crate::wasm::utilities::wit_manager::WitManager;
11
12#[derive(Debug)]
13pub enum JavascriptWasmCompilerError {
14    FsError(String),
15    CommandFailed(String),
16    CompileFailed(String),
17}
18
19impl fmt::Display for JavascriptWasmCompilerError {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            JavascriptWasmCompilerError::FsError(msg) => write!(f, "Filesystem error > {}", msg),
23            JavascriptWasmCompilerError::CommandFailed(msg) => {
24                write!(f, "Command failed > {}", msg)
25            }
26            JavascriptWasmCompilerError::CompileFailed(msg) => {
27                write!(f, "Compilation failed > {}", msg)
28            }
29        }
30    }
31}
32
33impl From<std::io::Error> for JavascriptWasmCompilerError {
34    fn from(err: std::io::Error) -> Self {
35        JavascriptWasmCompilerError::FsError(err.to_string())
36    }
37}
38
39impl From<std::time::SystemTimeError> for JavascriptWasmCompilerError {
40    fn from(err: std::time::SystemTimeError) -> Self {
41        JavascriptWasmCompilerError::FsError(err.to_string())
42    }
43}
44
45pub struct JavascriptWasmCompiler {
46    pub source_path: PathBuf,
47    pub cache_dir: PathBuf,
48    pub output_wasm: PathBuf,
49}
50
51impl JavascriptWasmCompiler {
52    pub fn new(source_path: &Path) -> Result<Self, JavascriptWasmCompilerError> {
53        let source_path = source_path.canonicalize().map_err(|e| {
54            JavascriptWasmCompilerError::FsError(format!("Cannot resolve source path: {}", e))
55        })?;
56
57        let cache_dir = std::env::current_dir()
58            .map_err(|e| {
59                JavascriptWasmCompilerError::FsError(format!("Cannot get current directory: {}", e))
60            })?
61            .join(".capsule");
62
63        let wasm_dir = cache_dir.join("wasm");
64        fs::create_dir_all(&wasm_dir)?;
65
66        let wasm_filename = generate_wasm_filename(&source_path);
67        let output_wasm = wasm_dir.join(wasm_filename);
68
69        Ok(Self {
70            source_path,
71            cache_dir,
72            output_wasm,
73        })
74    }
75
76    fn npx_command() -> Command {
77        if Command::new("npx.cmd")
78            .arg("--version")
79            .stdout(Stdio::null())
80            .stderr(Stdio::null())
81            .status()
82            .is_ok()
83        {
84            Command::new("npx.cmd")
85        } else {
86            Command::new("npx")
87        }
88    }
89
90    fn normalize_path_for_command(path: &Path) -> PathBuf {
91        let path_str = path.to_string_lossy();
92        if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
93            return PathBuf::from(stripped);
94        }
95        path.to_path_buf()
96    }
97
98    fn normalize_path_for_import(path: &Path) -> String {
99        Self::normalize_path_for_command(path)
100            .to_string_lossy()
101            .replace('\\', "/")
102    }
103
104    fn find_node_modules(root_dir: &Path) -> Option<PathBuf> {
105        let mut current = root_dir.to_path_buf();
106        loop {
107            let node_modules = current.join("node_modules");
108            if node_modules.exists() && node_modules.is_dir() {
109                return Some(node_modules);
110            }
111            if !current.pop() {
112                return None;
113            }
114        }
115    }
116
117    fn find_package(package_name: &str, source_dir: &Path, sdk_path: &Path) -> PathBuf {
118        if let Some(project_node_modules) = Self::find_node_modules(source_dir) {
119            let project_path = project_node_modules.join(package_name);
120            if project_path.exists() {
121                return project_path;
122            }
123        }
124
125        let sdk_node_modules = sdk_path.join("node_modules").join(package_name);
126        if sdk_node_modules.exists() {
127            return sdk_node_modules;
128        }
129
130        source_dir.join("node_modules").join(package_name)
131    }
132
133    pub fn compile_wasm(&self, export: bool) -> Result<PathBuf, JavascriptWasmCompilerError> {
134        let source_dir = self.source_path.parent().ok_or_else(|| {
135            JavascriptWasmCompilerError::FsError("Cannot determine source directory".to_string())
136        })?;
137
138        if !SourceFingerprint::needs_rebuild(
139            &self.cache_dir,
140            source_dir,
141            &self.output_wasm,
142            &["js", "ts", "toml"],
143            &["node_modules", "dist"],
144        ) {
145            if export && let Some(file_stem) = self.source_path.file_stem() {
146                let export_path = source_dir.join(file_stem).with_extension("wasm");
147                let _ = fs::copy(&self.output_wasm, &export_path);
148            }
149
150            return Ok(self.output_wasm.clone());
151        }
152
153        let wit_path = self.get_wit_path()?;
154        let sdk_path = self.get_sdk_path()?;
155
156        let source_for_import = if self.source_path.extension().is_some_and(|ext| ext == "ts") {
157            self.transpile_typescript()?
158        } else {
159            self.source_path.clone()
160        };
161
162        let wrapper_path = self.cache_dir.join("_capsule_boot.js");
163        let bundled_path = self.cache_dir.join("_capsule_bundled.js");
164
165        let import_path = Self::normalize_path_for_import(
166            &source_for_import
167                .canonicalize()
168                .unwrap_or_else(|_| source_for_import.to_path_buf()),
169        );
170
171        let sdk_path_str = Self::normalize_path_for_import(&sdk_path);
172
173        let wrapper_content = format!(
174            r#"// Auto-generated bootloader for Capsule
175import * as hostApi from 'capsule:host/api';
176import * as fsTypes from 'wasi:filesystem/types@0.2.0';
177import * as fsPreopens from 'wasi:filesystem/preopens@0.2.0';
178import * as environment from 'wasi:cli/environment@0.2.0';
179globalThis['capsule:host/api'] = hostApi;
180globalThis['wasi:filesystem/types'] = fsTypes;
181globalThis['wasi:filesystem/preopens'] = fsPreopens;
182globalThis['wasi:cli/environment'] = environment;
183import '{}';
184import {{ exports }} from '{}/dist/app.js';
185export const taskRunner = exports;
186            "#,
187            import_path, sdk_path_str
188        );
189
190        fs::write(&wrapper_path, wrapper_content)?;
191
192        let wrapper_path_normalized = Self::normalize_path_for_command(&wrapper_path);
193        let bundled_path_normalized = Self::normalize_path_for_command(&bundled_path);
194        let wit_path_normalized = Self::normalize_path_for_command(&wit_path);
195        let sdk_path_normalized = Self::normalize_path_for_command(&sdk_path);
196        let output_wasm_normalized = Self::normalize_path_for_command(&self.output_wasm);
197
198        let path_browserify_path =
199            Self::find_package("path-browserify", source_dir, &sdk_path_normalized);
200        let buffer_package_path = Self::find_package("buffer", source_dir, &sdk_path_normalized);
201        let events_package_path = Self::find_package("events", source_dir, &sdk_path_normalized);
202
203        let os_polyfill_path = sdk_path_normalized.join("dist/polyfills/os.js");
204        let process_polyfill_path = sdk_path_normalized.join("dist/polyfills/process.js");
205        let url_polyfill_path = sdk_path_normalized.join("dist/polyfills/url.js");
206        let buffer_polyfill_path = sdk_path_normalized.join("dist/polyfills/buffer.js");
207        let events_polyfill_path = sdk_path_normalized.join("dist/polyfills/events.js");
208        let stream_polyfill_path = sdk_path_normalized.join("dist/polyfills/stream.js");
209        let fs_polyfill_path = sdk_path_normalized.join("dist/polyfills/fs.js");
210
211        let esbuild_output = Self::npx_command()
212            .arg("esbuild")
213            .arg(&wrapper_path_normalized)
214            .arg("--bundle")
215            .arg("--format=esm")
216            .arg("--platform=neutral")
217            .arg("--main-fields=main,module")
218            .arg("--external:capsule:host/api")
219            .arg("--external:wasi:filesystem/*")
220            .arg("--external:wasi:cli/*")
221            .arg(format!("--inject:{}", process_polyfill_path.display()))
222            .arg(format!("--inject:{}", buffer_polyfill_path.display()))
223            .arg(format!("--inject:{}", events_polyfill_path.display()))
224            .arg(format!("--alias:path={}", path_browserify_path.display()))
225            .arg(format!(
226                "--alias:node:path={}",
227                path_browserify_path.display()
228            ))
229            .arg(format!("--alias:os={}", os_polyfill_path.display()))
230            .arg(format!("--alias:node:os={}", os_polyfill_path.display()))
231            .arg(format!(
232                "--alias:process={}",
233                process_polyfill_path.display()
234            ))
235            .arg(format!(
236                "--alias:node:process={}",
237                process_polyfill_path.display()
238            ))
239            .arg(format!("--alias:url={}", url_polyfill_path.display()))
240            .arg(format!("--alias:node:url={}", url_polyfill_path.display()))
241            .arg(format!("--alias:buffer={}", buffer_package_path.display()))
242            .arg(format!(
243                "--alias:node:buffer={}",
244                buffer_package_path.display()
245            ))
246            .arg(format!("--alias:events={}", events_package_path.display()))
247            .arg(format!(
248                "--alias:node:events={}",
249                events_package_path.display()
250            ))
251            .arg(format!("--alias:stream={}", stream_polyfill_path.display()))
252            .arg(format!(
253                "--alias:node:stream={}",
254                stream_polyfill_path.display()
255            ))
256            .arg(format!("--alias:fs={}", fs_polyfill_path.display()))
257            .arg(format!("--alias:node:fs={}", fs_polyfill_path.display()))
258            .arg(format!(
259                "--alias:fs/promises={}",
260                sdk_path_normalized
261                    .join("dist/polyfills/fs-promises.js")
262                    .display()
263            ))
264            .arg(format!(
265                "--alias:node:fs/promises={}",
266                sdk_path_normalized
267                    .join("dist/polyfills/fs-promises.js")
268                    .display()
269            ))
270            .arg(format!(
271                "--alias:stream/web={}",
272                sdk_path_normalized
273                    .join("dist/polyfills/stream-web.js")
274                    .display()
275            ))
276            .arg(format!(
277                "--alias:node:stream/web={}",
278                sdk_path_normalized
279                    .join("dist/polyfills/stream-web.js")
280                    .display()
281            ))
282            .arg(format!("--outfile={}", bundled_path_normalized.display()))
283            .current_dir(&sdk_path_normalized)
284            .stdout(Stdio::piped())
285            .stderr(Stdio::piped())
286            .output()?;
287
288        if !esbuild_output.status.success() {
289            return Err(JavascriptWasmCompilerError::CompileFailed(format!(
290                "Bundling failed: {}",
291                String::from_utf8_lossy(&esbuild_output.stderr).trim()
292            )));
293        }
294
295        let jco_output = Self::npx_command()
296            .arg("jco")
297            .arg("componentize")
298            .arg(&bundled_path_normalized)
299            .arg("--wit")
300            .arg(&wit_path_normalized)
301            .arg("--world-name")
302            .arg("capsule-agent")
303            .arg("--enable")
304            .arg("http")
305            .arg("-o")
306            .arg(&output_wasm_normalized)
307            .current_dir(&sdk_path_normalized)
308            .stdout(Stdio::piped())
309            .stderr(Stdio::piped())
310            .output()?;
311
312        if !jco_output.status.success() {
313            return Err(JavascriptWasmCompilerError::CompileFailed(format!(
314                "Component creation failed: {}",
315                String::from_utf8_lossy(&jco_output.stderr).trim()
316            )));
317        }
318
319        let _ = SourceFingerprint::update_after_build(
320            &self.cache_dir,
321            source_dir,
322            &["js", "ts", "toml"],
323            &["node_modules", "dist"],
324        );
325
326        if export && let Some(file_stem) = self.source_path.file_stem() {
327            let export_path = source_dir.join(file_stem).with_extension("wasm");
328            let _ = fs::copy(&self.output_wasm, &export_path);
329        }
330
331        Ok(self.output_wasm.clone())
332    }
333
334    fn get_wit_path(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
335        let wit_dir = self.cache_dir.join("wit");
336
337        if !wit_dir.join("capsule.wit").exists() {
338            WitManager::import_wit_deps(&wit_dir)?;
339        }
340
341        Ok(wit_dir)
342    }
343
344    fn get_sdk_path(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
345        if let Some(source_dir) = self.source_path.parent()
346            && let Some(node_modules) = Self::find_node_modules(source_dir)
347        {
348            let sdk_path = node_modules.join("@capsule-run/sdk");
349            if sdk_path.exists() {
350                return Ok(sdk_path);
351            }
352        }
353
354        if let Ok(exe_path) = std::env::current_exe()
355            && let Some(project_root) = exe_path
356                .parent()
357                .and_then(|p| p.parent())
358                .and_then(|p| p.parent())
359                .and_then(|p| p.parent())
360                .and_then(|p| p.parent())
361        {
362            let sdk_path = project_root.join("crates/capsule-sdk/javascript");
363            if sdk_path.exists() {
364                return Ok(sdk_path);
365            }
366        }
367
368        Err(JavascriptWasmCompilerError::FsError(
369            "Could not find JavaScript SDK.".to_string(),
370        ))
371    }
372
373    fn transpile_typescript(&self) -> Result<PathBuf, JavascriptWasmCompilerError> {
374        let output_path = self.cache_dir.join(
375            self.source_path
376                .file_stem()
377                .and_then(|s| s.to_str())
378                .map(|s| format!("{}.js", s))
379                .ok_or_else(|| {
380                    JavascriptWasmCompilerError::FsError("Invalid source filename".to_string())
381                })?,
382        );
383
384        let output = Self::npx_command()
385            .arg("tsc")
386            .arg(&self.source_path)
387            .arg("--outDir")
388            .arg(&self.cache_dir)
389            .arg("--module")
390            .arg("esnext")
391            .arg("--target")
392            .arg("esnext")
393            .arg("--moduleResolution")
394            .arg("bundler")
395            .arg("--esModuleInterop")
396            .arg("--skipLibCheck")
397            .stdout(Stdio::piped())
398            .stderr(Stdio::piped())
399            .output()?;
400
401        if !output.status.success() {
402            let stdout = String::from_utf8_lossy(&output.stdout);
403            let stderr = String::from_utf8_lossy(&output.stderr);
404
405            return Err(JavascriptWasmCompilerError::CompileFailed(format!(
406                "TypeScript compilation failed: {}{}",
407                stderr.trim(),
408                if !stdout.is_empty() {
409                    format!("\nstdout: {}", stdout.trim())
410                } else {
411                    String::new()
412                }
413            )));
414        }
415
416        if !output_path.exists() {
417            return Err(JavascriptWasmCompilerError::FsError(format!(
418                "TypeScript transpilation did not produce expected output: {}",
419                output_path.display()
420            )));
421        }
422
423        Ok(output_path)
424    }
425
426    pub fn introspect_task_registry(&self) -> Option<HashMap<String, serde_json::Value>> {
427        let source = fs::read_to_string(&self.source_path).ok()?;
428        let is_typescript = self
429            .source_path
430            .extension()
431            .is_some_and(|ext| ext == "ts" || ext == "mts");
432        extract_js_task_configs(&source, is_typescript)
433    }
434}