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 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}