use std::io::Write;
use std::process::{Command, Stdio};
use camino::Utf8Path;
use thiserror::Error;
use crate::core::Hash32;
use crate::{Blueprint, engine::Many, error::HauchiwaError, loader::GlobBundle};
#[derive(Debug, Error)]
pub enum ScriptError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Esbuild execution failed: {0}")]
Esbuild(String),
#[error("UTF-8 conversion error: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("Build error: {0}")]
Build(#[from] crate::error::BuildError),
}
pub struct ScriptLoader<'a, G>
where
G: Send + Sync,
{
blueprint: &'a mut Blueprint<G>,
entry_globs: Vec<String>,
watch_globs: Vec<String>,
bundle: bool,
minify: bool,
externals: Vec<String>,
}
impl<'a, G> ScriptLoader<'a, G>
where
G: Send + Sync + 'static,
{
fn new(blueprint: &'a mut Blueprint<G>) -> Self {
Self {
blueprint,
entry_globs: Vec::new(),
watch_globs: Vec::new(),
bundle: true,
minify: true,
externals: Vec::new(),
}
}
pub fn entry(mut self, glob: impl Into<String>) -> Self {
self.entry_globs.push(glob.into());
self
}
pub fn watch(mut self, glob: impl Into<String>) -> Self {
self.watch_globs.push(glob.into());
self
}
pub fn bundle(mut self, bundle: bool) -> Self {
self.bundle = bundle;
self
}
pub fn minify(mut self, minify: bool) -> Self {
self.minify = minify;
self
}
pub fn external(mut self, package: impl Into<String>) -> Self {
self.externals.push(package.into());
self
}
pub fn register(self) -> Result<Many<super::Script>, HauchiwaError> {
let watch_globs = if self.watch_globs.is_empty() {
self.entry_globs.clone()
} else {
self.watch_globs
};
let bundle = self.bundle;
let minify = self.minify;
let externals = self.externals;
let task = GlobBundle::new(self.entry_globs, watch_globs, move |_, store, input| {
for package in &externals {
let data = bundle_package(package, minify)?;
let path = store.save(&data, "js").map_err(ScriptError::Build)?;
store.register(package.as_str(), path.as_str());
}
let data = compile_esbuild(&input.path, bundle, minify, &externals)?;
let hash = Hash32::hash(&data);
let path = store.save(&data, "js").map_err(ScriptError::Build)?;
Ok((hash, input.path, super::Script { path }))
})?;
Ok(self.blueprint.add_task_fine(task))
}
}
impl<G> Blueprint<G>
where
G: Send + Sync + 'static,
{
pub fn load_esbuild(&mut self) -> ScriptLoader<'_, G> {
ScriptLoader::new(self)
}
}
fn compile_esbuild(
file: &Utf8Path,
bundle: bool,
minify: bool,
externals: &[String],
) -> Result<Vec<u8>, ScriptError> {
let mut cmd = Command::new("esbuild");
cmd.arg(file.as_str()).arg("--format=esm");
if bundle {
cmd.arg("--bundle");
}
if minify {
cmd.arg("--minify");
}
for package in externals {
cmd.arg(format!("--external:{package}"));
}
let output = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
if !output.status.success() {
return Err(ScriptError::Esbuild(String::from_utf8(output.stderr)?));
}
Ok(output.stdout)
}
fn bundle_package(package: &str, minify: bool) -> Result<Vec<u8>, ScriptError> {
let stdin_content = format!("export * from '{package}'");
let mut cmd = Command::new("esbuild");
cmd.arg("--bundle")
.arg("--format=esm")
.arg("--platform=browser")
.arg("--loader=js");
if minify {
cmd.arg("--minify");
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
child
.stdin
.take()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "stdin was not piped"))?
.write_all(stdin_content.as_bytes())?;
let output = child.wait_with_output()?;
if !output.status.success() {
return Err(ScriptError::Esbuild(String::from_utf8(output.stderr)?));
}
Ok(output.stdout)
}