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