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