Skip to main content

mini_static/
js.rs

1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::tool;
6
7/// External tools mini-static 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/// mini-static does not install or manage these binaries — only looks them up on
15/// `PATH` at server startup and fails loudly if missing (see [`crate::Server::with_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
75/// Configuration for [`crate::Server::with_js_tool`]: independent `bundle`/`minify`
76/// toggles, all four combinations valid.
77///
78/// Unlike CSS (which discovers and concatenates every source file with no ambiguity),
79/// JS module graphs are entry-point-driven — concatenating independent files risks
80/// scope collisions and undefined evaluation order. `bundle` mode therefore requires
81/// an explicit entry point via [`JsOptions::bundle_entry`]; without it, `bundle: true`
82/// has no effect (see [`crate::Server::with_js_tool`], which validates this at
83/// configuration time).
84#[derive(Debug, Clone, Default)]
85pub struct JsOptions {
86    bundle: bool,
87    minify: bool,
88    entry: Option<PathBuf>,
89    bundle_output_name: Option<String>,
90}
91
92impl JsOptions {
93    /// Neither bundle nor minify — JS is copied through unchanged.
94    pub fn new() -> Self {
95        JsOptions::default()
96    }
97
98    /// Minify JS via the configured [`JsTool`], per file, mirroring each source file's
99    /// relative path into the output dir (no bundling).
100    pub fn minify(mut self, minify: bool) -> Self {
101        self.minify = minify;
102        self
103    }
104
105    /// Enable single-entry-point bundling: `entry` is bundled (optionally minified,
106    /// per [`Self::minify`]) into `<output_dir>/<output_name>` via the configured
107    /// [`JsTool`]. `entry` must lie under a registered source folder — validated by
108    /// [`crate::Server::with_js_tool`], not here.
109    pub fn bundle_entry(mut self, entry: &Path, output_name: impl Into<String>) -> Self {
110        self.bundle = true;
111        self.entry = Some(entry.to_path_buf());
112        self.bundle_output_name = Some(output_name.into());
113        self
114    }
115
116    pub(crate) fn is_bundle(&self) -> bool {
117        self.bundle
118    }
119
120    pub(crate) fn is_minify(&self) -> bool {
121        self.minify
122    }
123
124    pub(crate) fn entry(&self) -> Option<&Path> {
125        self.entry.as_deref()
126    }
127
128    pub(crate) fn output_file_name(&self) -> Option<&str> {
129        self.bundle_output_name.as_deref()
130    }
131}
132
133/// Errors from JS tool orchestration.
134#[derive(Debug)]
135pub(crate) enum JsError {
136    WriteOutput { path: PathBuf, reason: String },
137    Tool(tool::ToolError),
138}
139
140impl std::fmt::Display for JsError {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        match self {
143            JsError::WriteOutput { path, reason } => {
144                write!(f, "failed to write {}: {reason}", path.display())
145            }
146            JsError::Tool(e) => write!(f, "{e}"),
147        }
148    }
149}
150
151impl std::error::Error for JsError {}
152
153impl From<tool::ToolError> for JsError {
154    fn from(e: tool::ToolError) -> Self {
155        JsError::Tool(e)
156    }
157}
158
159/// Bundle `entry` (and its resolved module graph) into `output_path`, per `options`.
160///
161/// # Errors
162///
163/// Returns `Err` without touching `output_path` on tool failure — a broken rebuild
164/// leaves the previous good output in place.
165pub(crate) async fn build_js_bundle(
166    js_tool: JsTool,
167    options: &JsOptions,
168    entry: &Path,
169    output_path: &Path,
170) -> Result<(), JsError> {
171    if !options.bundle && !options.minify {
172        return copy_file(entry, output_path);
173    }
174    Ok(run_tool(js_tool, true, options.minify, entry, output_path).await?)
175}
176
177/// Process a single JS file (bundle disabled) into its mirrored `output` path.
178///
179/// A malformed source degrades to a raw copy rather than failing the pipeline — the
180/// server must still serve the file and keep the browser in sync. The degradation is
181/// logged, not silent.
182pub(crate) async fn build_js_file(
183    js_tool: JsTool,
184    options: &JsOptions,
185    source: &Path,
186    output: &Path,
187) -> Result<(), JsError> {
188    if !options.minify || is_already_minified(source) {
189        return copy_file(source, output);
190    }
191
192    if let Err(e) = run_tool(js_tool, false, true, source, output).await {
193        eprintln!(
194            "js tool: minify failed for {}, serving raw bytes: {e}",
195            source.display()
196        );
197        return copy_file(source, output);
198    }
199    Ok(())
200}
201
202async fn run_tool(
203    js_tool: JsTool,
204    bundle: bool,
205    minify: bool,
206    entry: &Path,
207    output: &Path,
208) -> Result<(), tool::ToolError> {
209    if let Some(parent) = output.parent() {
210        let _ = fs::create_dir_all(parent);
211    }
212    let args = js_tool.args(bundle, minify, entry, output);
213    tool::execute(
214        js_tool.binary_name(),
215        js_tool.install_hint(),
216        js_tool.binary_name(),
217        &args,
218        output,
219        tool::TOOL_TIMEOUT,
220    )
221    .await
222}
223
224/// True if `path`'s filename indicates it's already minified (`*.min.js`). Such files
225/// should be served as-is — running a minifier on already-minified input is wasted
226/// work at best and a correctness risk at worst.
227fn is_already_minified(path: &Path) -> bool {
228    path.file_name()
229        .and_then(|name| name.to_str())
230        .is_some_and(|name| name.ends_with(".min.js"))
231}
232
233fn copy_file(source: &Path, output: &Path) -> Result<(), JsError> {
234    if let Some(parent) = output.parent() {
235        fs::create_dir_all(parent).map_err(|e| JsError::WriteOutput {
236            path: output.to_path_buf(),
237            reason: e.to_string(),
238        })?;
239    }
240    fs::copy(source, output).map_err(|e| JsError::WriteOutput {
241        path: output.to_path_buf(),
242        reason: e.to_string(),
243    })?;
244    Ok(())
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use tempfile::TempDir;
251
252    #[tokio::test]
253    async fn bundle_mode_runs_the_tool_against_the_entry() {
254        let src = TempDir::new().unwrap();
255        let out = TempDir::new().unwrap();
256        let entry = src.path().join("main.js");
257        let output = out.path().join("bundle.js");
258        fs::write(&entry, "const x = 1;").unwrap();
259
260        build_js_bundle(
261            JsTool::TestEcho,
262            &JsOptions::new().bundle_entry(&entry, "bundle.js"),
263            &entry,
264            &output,
265        )
266        .await
267        .unwrap();
268
269        assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
270    }
271
272    #[tokio::test]
273    async fn bundle_false_minify_false_is_a_passthrough_copy() {
274        let src = TempDir::new().unwrap();
275        let out = TempDir::new().unwrap();
276        let entry = src.path().join("main.js");
277        let output = out.path().join("main.js");
278        fs::write(&entry, "const x = 1;").unwrap();
279
280        build_js_bundle(JsTool::TestMissing, &JsOptions::new(), &entry, &output)
281            .await
282            .unwrap();
283
284        assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
285    }
286
287    #[tokio::test]
288    async fn a_failing_bundle_tool_leaves_previous_output_untouched() {
289        let src = TempDir::new().unwrap();
290        let out = TempDir::new().unwrap();
291        let entry = src.path().join("main.js");
292        let output = out.path().join("bundle.js");
293        fs::write(&entry, "const x = 1;").unwrap();
294        fs::write(&output, "/* previous good build */").unwrap();
295
296        let result = build_js_bundle(
297            JsTool::TestMissing,
298            &JsOptions::new()
299                .bundle_entry(&entry, "bundle.js")
300                .minify(true),
301            &entry,
302            &output,
303        )
304        .await;
305
306        assert!(result.is_err());
307        assert_eq!(
308            fs::read_to_string(&output).unwrap(),
309            "/* previous good build */"
310        );
311    }
312
313    #[tokio::test]
314    async fn per_file_mode_with_minify_false_copies_through_unchanged() {
315        let src = TempDir::new().unwrap();
316        let out = TempDir::new().unwrap();
317        let source = src.path().join("app.js");
318        let output = out.path().join("app.js");
319        fs::write(&source, "const x = 1;").unwrap();
320
321        build_js_file(JsTool::TestMissing, &JsOptions::new(), &source, &output)
322            .await
323            .unwrap();
324
325        assert_eq!(fs::read_to_string(&output).unwrap(), "const x = 1;");
326    }
327
328    #[tokio::test]
329    async fn per_file_mode_already_minified_skips_the_tool() {
330        let src = TempDir::new().unwrap();
331        let out = TempDir::new().unwrap();
332        let source = src.path().join("app.min.js");
333        let output = out.path().join("app.min.js");
334        fs::write(&source, "const x=1;").unwrap();
335
336        build_js_file(
337            JsTool::TestMissing,
338            &JsOptions::new().minify(true),
339            &source,
340            &output,
341        )
342        .await
343        .unwrap();
344
345        assert_eq!(fs::read_to_string(&output).unwrap(), "const x=1;");
346    }
347
348    #[tokio::test]
349    async fn per_file_mode_degrades_to_raw_copy_when_the_tool_fails() {
350        let src = TempDir::new().unwrap();
351        let out = TempDir::new().unwrap();
352        let source = src.path().join("app.js");
353        let output = out.path().join("app.js");
354        fs::write(&source, "const x = 1;").unwrap();
355
356        build_js_file(
357            JsTool::TestMissing,
358            &JsOptions::new().minify(true),
359            &source,
360            &output,
361        )
362        .await
363        .unwrap();
364
365        assert_eq!(
366            fs::read_to_string(&output).unwrap(),
367            "const x = 1;",
368            "a failing tool must degrade to serving the raw source, not fail the pipeline"
369        );
370    }
371}