capsule_core/wasm/compiler/
javascript.rs1use 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}