Skip to main content

mini_build/
js.rs

1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::tool::{self, group_by_parent};
6
7/// External tools this crate knows how to invoke for JS bundling/minification.
8///
9/// `#[non_exhaustive]` so a future preset is an additive variant, not a
10/// semver-breaking change for downstream `match` expressions.
11///
12/// # Installation
13///
14/// This crate does not install or manage these binaries — only looks them up on
15/// `PATH` before a build starts and fails loudly if missing (see [`crate::Builder::js_tool`]).
16#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum JsTool {
19    /// <https://esbuild.github.io>, invoked via its `esbuild` CLI (npm package
20    /// `esbuild`, also distributable as a standalone platform binary).
21    Esbuild,
22    /// Copies the entry/source file to the output file unchanged. Test-only.
23    #[cfg(test)]
24    TestEcho,
25    /// Always fails with `ToolError::NotFound`. Test-only.
26    #[cfg(test)]
27    TestMissing,
28}
29
30impl JsTool {
31    pub(crate) fn binary_name(&self) -> &'static str {
32        match self {
33            JsTool::Esbuild => "esbuild",
34            #[cfg(test)]
35            JsTool::TestEcho => "cp",
36            #[cfg(test)]
37            JsTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
38        }
39    }
40
41    pub(crate) fn install_hint(&self) -> &'static str {
42        match self {
43            JsTool::Esbuild => {
44                "install via `npm install -g esbuild` (or add it as a project \
45                 devDependency and put its bin/ on PATH)"
46            }
47            #[cfg(test)]
48            JsTool::TestEcho | JsTool::TestMissing => "test-only tool, not installable",
49        }
50    }
51
52    fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
53        match self {
54            JsTool::Esbuild => {
55                let mut args = vec![OsString::from(entry)];
56                if bundle {
57                    args.push(OsString::from("--bundle"));
58                }
59                if minify {
60                    args.push(OsString::from("--minify"));
61                }
62                let mut outfile = OsString::from("--outfile=");
63                outfile.push(output);
64                args.push(outfile);
65                args
66            }
67            #[cfg(test)]
68            JsTool::TestEcho => vec![entry.into(), output.into()],
69            #[cfg(test)]
70            JsTool::TestMissing => vec![],
71        }
72    }
73
74    /// Arguments for transforming many inputs in one invocation, writing into `out_dir`.
75    ///
76    /// Process startup dominates a build, so N files should cost one spawn rather than N.
77    /// `esbuild` resolves `--outdir` against the inputs' common base directory; callers
78    /// pass inputs that share a parent, so results land flat in `out_dir` with their
79    /// original basenames.
80    fn batch_args(&self, minify: bool, inputs: &[PathBuf], out_dir: &Path) -> Vec<OsString> {
81        match self {
82            JsTool::Esbuild => {
83                let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
84                if minify {
85                    args.push(OsString::from("--minify"));
86                }
87                let mut outdir = OsString::from("--outdir=");
88                outdir.push(out_dir);
89                args.push(outdir);
90                args
91            }
92            #[cfg(test)]
93            JsTool::TestEcho => {
94                let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
95                args.push(out_dir.into());
96                args
97            }
98            #[cfg(test)]
99            JsTool::TestMissing => vec![],
100        }
101    }
102}
103
104/// Configuration for [`crate::Builder::js_tool`]: independent `bundle`/`minify`
105/// toggles, all four combinations valid.
106///
107/// Unlike CSS (which discovers and concatenates every source file with no ambiguity),
108/// JS module graphs are entry-point-driven — concatenating independent files risks
109/// scope collisions and undefined evaluation order. `bundle` mode therefore requires
110/// an explicit entry point via [`JsOptions::bundle_entry`]; without it, `bundle: true`
111/// has no effect (see [`crate::Builder::js_tool`], which validates this at
112/// configuration time).
113#[derive(Debug, Clone, Default)]
114pub struct JsOptions {
115    bundle: bool,
116    minify: bool,
117    entry: Option<PathBuf>,
118    bundle_output_name: Option<String>,
119}
120
121impl JsOptions {
122    /// Neither bundle nor minify — JS is copied through unchanged.
123    pub fn new() -> Self {
124        JsOptions::default()
125    }
126
127    /// Minify JS via the configured [`JsTool`], per file, mirroring each source file's
128    /// relative path into the output dir (no bundling).
129    pub fn minify(mut self, minify: bool) -> Self {
130        self.minify = minify;
131        self
132    }
133
134    /// Enable single-entry-point bundling: `entry` is bundled (optionally minified,
135    /// per [`Self::minify`]) into `<output_dir>/<output_name>` via the configured
136    /// [`JsTool`]. `entry` must lie under a registered source folder — validated by
137    /// [`crate::Builder::js_tool`], not here.
138    pub fn bundle_entry(mut self, entry: &Path, output_name: impl Into<String>) -> Self {
139        self.bundle = true;
140        self.entry = Some(entry.to_path_buf());
141        self.bundle_output_name = Some(output_name.into());
142        self
143    }
144
145    pub(crate) fn is_bundle(&self) -> bool {
146        self.bundle
147    }
148
149    pub(crate) fn is_minify(&self) -> bool {
150        self.minify
151    }
152
153    pub(crate) fn entry(&self) -> Option<&Path> {
154        self.entry.as_deref()
155    }
156
157    pub(crate) fn output_file_name(&self) -> Option<&str> {
158        self.bundle_output_name.as_deref()
159    }
160}
161
162/// Errors from JS tool orchestration.
163#[derive(Debug)]
164pub enum JsError {
165    WriteOutput { path: PathBuf, reason: String },
166    Tool(tool::ToolError),
167}
168
169impl std::fmt::Display for JsError {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            JsError::WriteOutput { path, reason } => {
173                write!(f, "failed to write {}: {reason}", path.display())
174            }
175            JsError::Tool(e) => write!(f, "{e}"),
176        }
177    }
178}
179
180impl std::error::Error for JsError {}
181
182impl From<tool::ToolError> for JsError {
183    fn from(e: tool::ToolError) -> Self {
184        JsError::Tool(e)
185    }
186}
187
188/// Bundle `entry` (and its resolved module graph) into `output_path`, per `options`.
189///
190/// # Errors
191///
192/// Returns `Err` without touching `output_path` on tool failure — a broken rebuild
193/// leaves the previous good output in place.
194pub(crate) fn build_js_bundle(
195    js_tool: JsTool,
196    options: &JsOptions,
197    entry: &Path,
198    output_path: &Path,
199) -> Result<(), JsError> {
200    if !options.bundle && !options.minify {
201        return copy_file(entry, output_path);
202    }
203    Ok(run_tool(js_tool, true, options.minify, entry, output_path)?)
204}
205
206/// Process a single JS file (bundle disabled) into its mirrored `output` path.
207///
208/// A malformed source degrades to a raw copy rather than failing the pipeline — the
209/// server must still serve the file and keep the browser in sync. The degradation is
210/// logged, not silent.
211/// Transform every path in `inputs` in a single invocation, writing into `out_dir`.
212fn run_tool_batch(
213    js_tool: JsTool,
214    minify: bool,
215    inputs: &[PathBuf],
216    out_dir: &Path,
217    expected: &[PathBuf],
218) -> Result<(), tool::ToolError> {
219    let args = js_tool.batch_args(minify, inputs, out_dir);
220    let expected: Vec<&Path> = expected.iter().map(PathBuf::as_path).collect();
221    tool::execute(
222        js_tool.binary_name(),
223        js_tool.install_hint(),
224        js_tool.binary_name(),
225        &args,
226        &expected,
227        tool::TOOL_TIMEOUT,
228    )
229}
230
231/// Transform many files in as few invocations as possible, each into its mirrored
232/// `output`. See `css::build_css_files` — same shape, same fallback, same reasons.
233pub(crate) fn build_js_files(
234    js_tool: JsTool,
235    options: &JsOptions,
236    pairs: &[(PathBuf, PathBuf)],
237) -> Result<(), JsError> {
238    let (transform, bypass): (Vec<_>, Vec<_>) = pairs
239        .iter()
240        .partition(|(source, _)| options.minify && !is_already_minified(source));
241
242    for (source, output) in bypass {
243        copy_file(source, output)?;
244    }
245    if transform.is_empty() {
246        return Ok(());
247    }
248
249    let by_output_dir = group_by_parent(
250        &transform
251            .iter()
252            .map(|(_, output)| output.clone())
253            .collect::<Vec<_>>(),
254    );
255
256    for (out_dir, outputs) in by_output_dir {
257        fs::create_dir_all(&out_dir).map_err(|e| JsError::WriteOutput {
258            path: out_dir.clone(),
259            reason: e.to_string(),
260        })?;
261
262        let group: Vec<&(PathBuf, PathBuf)> = transform
263            .iter()
264            .copied()
265            .filter(|(_, output)| outputs.contains(output))
266            .collect();
267        let inputs: Vec<PathBuf> = group.iter().map(|(source, _)| source.clone()).collect();
268
269        if run_tool_batch(js_tool, true, &inputs, &out_dir, &outputs).is_err() {
270            for (source, output) in group {
271                build_js_file(js_tool, options, source, output)?;
272            }
273        }
274    }
275
276    Ok(())
277}
278
279pub(crate) fn build_js_file(
280    js_tool: JsTool,
281    options: &JsOptions,
282    source: &Path,
283    output: &Path,
284) -> Result<(), JsError> {
285    if !options.minify || is_already_minified(source) {
286        return copy_file(source, output);
287    }
288
289    if let Err(e) = run_tool(js_tool, false, true, source, output) {
290        eprintln!(
291            "js tool: minify failed for {}, serving raw bytes: {e}",
292            source.display()
293        );
294        return copy_file(source, output);
295    }
296    Ok(())
297}
298
299fn run_tool(
300    js_tool: JsTool,
301    bundle: bool,
302    minify: bool,
303    entry: &Path,
304    output: &Path,
305) -> Result<(), tool::ToolError> {
306    if let Some(parent) = output.parent() {
307        let _ = fs::create_dir_all(parent);
308    }
309    let args = js_tool.args(bundle, minify, entry, output);
310    tool::execute(
311        js_tool.binary_name(),
312        js_tool.install_hint(),
313        js_tool.binary_name(),
314        &args,
315        &[output],
316        tool::TOOL_TIMEOUT,
317    )
318}
319
320/// True if `path`'s filename indicates it's already minified (`*.min.js`). Such files
321/// should be served as-is — running a minifier on already-minified input is wasted
322/// work at best and a correctness risk at worst.
323fn is_already_minified(path: &Path) -> bool {
324    path.file_name()
325        .and_then(|name| name.to_str())
326        .is_some_and(|name| name.ends_with(".min.js"))
327}
328
329fn copy_file(source: &Path, output: &Path) -> Result<(), JsError> {
330    if let Some(parent) = output.parent() {
331        fs::create_dir_all(parent).map_err(|e| JsError::WriteOutput {
332            path: output.to_path_buf(),
333            reason: e.to_string(),
334        })?;
335    }
336    fs::copy(source, output).map_err(|e| JsError::WriteOutput {
337        path: output.to_path_buf(),
338        reason: e.to_string(),
339    })?;
340    Ok(())
341}
342
343#[cfg(test)]
344#[path = "../tests/unit/js.rs"]
345mod tests;