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