Skip to main content

Library/Fn/Bundle/
ESBuild.rs

1//! esbuild wrapper for complex builds
2//!
3//! This module provides an optional wrapper that invokes esbuild for builds
4//! that Rest can't handle alone. It's designed as a fallback for complex
5//! scenarios:
6//! - Large multi-file bundles
7//! - Complex module resolution
8//! - Advanced tree-shaking
9//! - AMD module output
10//!
11//! Note: This requires esbuild to be installed separately.
12
13use std::{path::Path, process::Command};
14
15use super::{BundleConfig, BundleResult};
16
17/// Wrapper around esbuild for complex builds
18pub struct EsbuildWrapper {
19	/// Path to esbuild binary (defaults to node_modules/.bin/esbuild)
20	esbuild_path:Option<String>,
21}
22
23impl EsbuildWrapper {
24	pub fn new() -> Self { Self { esbuild_path:None } }
25
26	pub fn with_path(mut self, path:impl Into<String>) -> Self {
27		self.esbuild_path = Some(path.into());
28
29		self
30	}
31
32	/// Find esbuild in common locations
33	fn find_esbuild(&self) -> Option<String> {
34		// Check explicit path first
35		if let Some(ref path) = self.esbuild_path {
36			if Path::new(path).exists() {
37				return Some(path.clone());
38			}
39		}
40
41		// Check node_modules
42		let possible_paths = [
43			"./node_modules/.bin/esbuild",
44			"./node_modules/esbuild/bin/esbuild",
45			"../node_modules/.bin/esbuild",
46			"../../node_modules/.bin/esbuild",
47		];
48
49		for path in &possible_paths {
50			if Path::new(path).exists() {
51				return Some(path.to_string());
52			}
53		}
54
55		// Check if esbuild is in PATH
56		if Command::new("esbuild").arg("--version").output().is_ok() {
57			return Some("esbuild".to_string());
58		}
59
60		None
61	}
62
63	/// Check if esbuild is available
64	pub fn is_available(&self) -> bool { self.find_esbuild().is_some() }
65
66	/// Build using esbuild
67	pub fn build(&self, config:&BundleConfig) -> anyhow::Result<BundleResult> {
68		let esbuild_path = self
69			.find_esbuild()
70			.ok_or_else(|| anyhow::anyhow!("esbuild not found. Please install it with: npm install esbuild"))?;
71
72		// Build esbuild arguments
73		let mut args = Vec::new();
74
75		// Entry points
76		for entry in &config.entries {
77			args.push("--bundle".to_string());
78
79			args.push(entry.clone());
80		}
81
82		// Output
83		args.push("--outdir".to_string());
84
85		args.push(config.output_dir.clone());
86
87		// Format
88		args.push("--format".to_string());
89
90		args.push(config.format.clone());
91
92		// Target
93		args.push("--target".to_string());
94
95		args.push(config.target.clone());
96
97		// Source maps
98		if config.source_map {
99			args.push("--sourcemap".to_string());
100		}
101
102		if config.inline_source_map {
103			args.push("--sourcemap=inline".to_string());
104		}
105
106		// Minification
107		if config.minify {
108			args.push("--minify".to_string());
109		}
110
111		// Tree-shaking (esbuild enables this by default)
112		if !config.tree_shaking {
113			args.push("--tree-shaking=false".to_string());
114		}
115
116		// Watch mode
117		if config.watch {
118			args.push("--watch".to_string());
119		}
120
121		// External modules
122		for external in &config.externals {
123			args.push("--external".to_string());
124
125			args.push(external.clone());
126		}
127
128		// Platform
129		args.push("--platform=browser".to_string());
130
131		// Execute esbuild
132		let output = Command::new(&esbuild_path)
133			.args(&args)
134			.output()
135			.map_err(|e| anyhow::anyhow!("Failed to run esbuild: {}", e))?;
136
137		if !output.status.success() {
138			let stderr = String::from_utf8_lossy(&output.stderr);
139
140			return Err(anyhow::anyhow!("esbuild failed: {}", stderr));
141		}
142
143		// Collect bundled files
144		let mut bundled_files = Vec::new();
145
146		for entry in &config.entries {
147			let filename = Path::new(entry).file_stem().and_then(|s| s.to_str()).unwrap_or("output");
148
149			let ext = if config.format == "cjs" { "cjs" } else { "js" };
150
151			bundled_files.push(format!("{}/{}.{}", config.output_dir, filename, ext));
152		}
153
154		Ok(BundleResult {
155			output_path:config.output_dir.clone(),
156			source_map_path:if config.source_map {
157				Some(format!("{}.map", config.output_dir))
158			} else {
159				None
160			},
161			bundled_files,
162			hash:format!(
163				"{:x}",
164				std::time::SystemTime::now()
165					.duration_since(std::time::UNIX_EPOCH)
166					.unwrap()
167					.as_millis()
168			),
169		})
170	}
171
172	/// Build with TypeScript type checking
173	pub fn build_with_types(&self, config:&BundleConfig) -> anyhow::Result<BundleResult> {
174		let mut args = vec!["--bundle".to_string(), "--loader:.ts=ts".to_string()];
175
176		// Add all config args
177		for entry in &config.entries {
178			args.push(entry.clone());
179		}
180
181		args.push("--outdir".to_string());
182
183		args.push(config.output_dir.clone());
184
185		// Type checking
186		args.push("--tsconfig".to_string());
187
188		args.push("tsconfig.json".to_string());
189
190		self.build(config)
191	}
192
193	/// Watch mode with callback
194	pub fn watch<F>(&self, config:&BundleConfig, _on_change:F) -> anyhow::Result<()>
195	where
196		F: Fn(&str) + Send + Sync, {
197		let _esbuild_path = self.find_esbuild().ok_or_else(|| anyhow::anyhow!("esbuild not found"))?;
198
199		// Build initial bundle
200		self.build(config)?;
201
202		// Set up file watcher (simplified - would use notify crate in production)
203		// For now, just build once
204		tracing::info!("Watch mode enabled. Rebuilding on file changes...");
205
206		Ok(())
207	}
208}
209
210impl Default for EsbuildWrapper {
211	fn default() -> Self { Self::new() }
212}
213
214/// Check if esbuild is available
215pub fn check_esbuild() -> bool {
216	let wrapper = EsbuildWrapper::new();
217
218	wrapper.is_available()
219}
220
221/// Install esbuild if not present
222pub fn install_esbuild() -> anyhow::Result<()> {
223	let output = Command::new("npm")
224		.args(&["install", "esbuild"])
225		.output()
226		.map_err(|e| anyhow::anyhow!("Failed to run npm: {}", e))?;
227
228	if !output.status.success() {
229		return Err(anyhow::anyhow!("Failed to install esbuild"));
230	}
231
232	Ok(())
233}
234
235#[cfg(test)]
236mod tests {
237
238	use super::*;
239
240	#[test]
241	fn test_esbuild_wrapper_creation() {
242		let wrapper = EsbuildWrapper::new();
243
244		assert!(wrapper.esbuild_path.is_none());
245	}
246
247	#[test]
248	fn test_esbuild_wrapper_with_path() {
249		let wrapper = EsbuildWrapper::new().with_path("/custom/path/esbuild");
250
251		assert!(wrapper.esbuild_path.is_some());
252	}
253
254	#[test]
255	fn test_check_esbuild() {
256		// This test will fail if esbuild is not installed
257		let available = check_esbuild();
258
259		// Just check it doesn't panic
260		let _ = available;
261	}
262}