Skip to main content

xtask_wasm/
dist.rs

1use crate::{
2    anyhow::{ensure, Context, Result},
3    camino, clap, default_build_command, metadata,
4};
5use derive_more::Debug;
6use std::{fs, path::PathBuf, process};
7use wasm_bindgen_cli_support::Bindgen;
8
9/// A type that can transform or copy a single asset file during [`Dist::build`].
10///
11/// Implement this trait to customise how individual files in the assets directory are
12/// processed before they land in the dist directory — for example to compile SASS to
13/// CSS, minify JavaScript, or generate additional output files from a source file.
14///
15/// Return `Ok(true)` if the file was handled (the transformer wrote its own output).
16/// Return `Ok(false)` to fall through to the next transformer, or to the default
17/// plain-copy behaviour if no transformer claims the file.
18///
19/// A blanket implementation is provided for `()` (no-op, always returns `Ok(false)`),
20/// so the trait is easy to stub out in tests.
21///
22/// # Examples
23///
24/// ```rust,no_run
25/// use std::path::Path;
26/// use xtask_wasm::{anyhow::Result, clap, Transformer};
27///
28/// struct UppercaseText;
29///
30/// impl Transformer for UppercaseText {
31///     fn transform(&self, source: &Path, dest: &Path) -> Result<bool> {
32///         if source.extension().and_then(|e| e.to_str()) == Some("txt") {
33///             let content = std::fs::read_to_string(source)?;
34///             std::fs::write(dest, content.to_uppercase())?;
35///             return Ok(true);
36///         }
37///         Ok(false)
38///     }
39/// }
40///
41/// #[derive(clap::Parser)]
42/// enum Opt {
43///     Dist(xtask_wasm::Dist),
44/// }
45///
46/// fn main() -> Result<()> {
47///     let opt: Opt = clap::Parser::parse();
48///
49///     match opt {
50///         Opt::Dist(dist) => {
51///             dist.transformer(UppercaseText)
52///                 .build("my-project")?;
53///         }
54///     }
55///
56///     Ok(())
57/// }
58/// ```
59pub trait Transformer {
60    /// Process a single asset file.
61    ///
62    /// `source` is the absolute path to the file in the assets directory.
63    /// `dest` is the intended output path inside the dist directory, preserving
64    /// the same relative path as `source` (the implementor may change the extension).
65    ///
66    /// Return `Ok(true)` if the file was handled, `Ok(false)` to defer.
67    fn transform(&self, source: &Path, dest: &Path) -> Result<bool>;
68}
69
70use std::path::Path;
71
72impl Transformer for () {
73    fn transform(&self, _source: &Path, _dest: &Path) -> Result<bool> {
74        Ok(false)
75    }
76}
77
78/// A helper to generate the distributed package.
79///
80/// # Usage
81///
82/// ```rust,no_run
83/// use std::process;
84/// use xtask_wasm::{anyhow::Result, clap};
85///
86/// #[derive(clap::Parser)]
87/// enum Opt {
88///     Dist(xtask_wasm::Dist),
89/// }
90///
91/// fn main() -> Result<()> {
92///     let opt: Opt = clap::Parser::parse();
93///
94///     match opt {
95///         Opt::Dist(dist) => {
96///             log::info!("Generating package...");
97///
98///             dist
99///                 .assets_dir("my-project/assets")
100///                 .app_name("my-project")
101///                 .build("my-project")?;
102///         }
103///     }
104///
105///     Ok(())
106/// }
107/// ```
108///
109/// In this example, we added a `dist` subcommand to build and package the
110/// `my-project` crate. It will run the [`default_build_command`](crate::default_build_command)
111/// at the workspace root, copy the content of the `my-project/assets` directory,
112/// generate JS bindings and output two files: `my-project.js` and `my-project.wasm`
113/// into the dist directory.
114#[non_exhaustive]
115#[derive(Debug, clap::Parser)]
116#[clap(
117    about = "Generate the distributed package.",
118    long_about = "Generate the distributed package.\n\
119        It will build and package the project for WASM."
120)]
121pub struct Dist {
122    /// No output printed to stdout.
123    #[clap(short, long)]
124    pub quiet: bool,
125    /// Number of parallel jobs, defaults to # of CPUs.
126    #[clap(short, long)]
127    pub jobs: Option<String>,
128    /// Build artifacts with the specified profile.
129    #[clap(long)]
130    pub profile: Option<String>,
131    /// Build artifacts in release mode, with optimizations.
132    #[clap(long)]
133    pub release: bool,
134    /// Space or comma separated list of features to activate.
135    #[clap(long)]
136    pub features: Vec<String>,
137    /// Activate all available features.
138    #[clap(long)]
139    pub all_features: bool,
140    /// Do not activate the `default` features.
141    #[clap(long)]
142    pub no_default_features: bool,
143    /// Use verbose output
144    #[clap(short, long)]
145    pub verbose: bool,
146    /// Coloring: auto, always, never.
147    #[clap(long)]
148    pub color: Option<String>,
149    /// Require Cargo.lock and cache are up to date.
150    #[clap(long)]
151    pub frozen: bool,
152    /// Require Cargo.lock is up to date.
153    #[clap(long)]
154    pub locked: bool,
155    /// Run without accessing the network.
156    #[clap(long)]
157    pub offline: bool,
158    /// Ignore `rust-version` specification in packages.
159    #[clap(long)]
160    pub ignore_rust_version: bool,
161    /// Name of the example target to run.
162    #[clap(long)]
163    pub example: Option<String>,
164
165    /// Command passed to the build process.
166    #[clap(skip = default_build_command())]
167    pub build_command: process::Command,
168    /// Directory of all generated artifacts.
169    #[clap(skip)]
170    pub dist_dir: Option<PathBuf>,
171    /// Directory of all static assets artifacts.
172    ///
173    /// Default to `assets` in the package root when it exists.
174    #[clap(skip)]
175    pub assets_dir: Option<PathBuf>,
176    /// Set the resulting app name, default to `app`.
177    #[clap(skip)]
178    pub app_name: Option<String>,
179    /// Transformers applied to each file in the assets directory during the build.
180    ///
181    /// Each transformer is called in order for every file; the first one that returns
182    /// `Ok(true)` claims the file and the rest are skipped. Files not claimed by any
183    /// transformer are copied verbatim into the dist directory.
184    #[clap(skip)]
185    #[debug(skip)]
186    pub transformers: Vec<Box<dyn Transformer>>,
187
188    /// Optional `wasm-opt` pass to run on the generated Wasm binary after bindgen.
189    ///
190    /// Set via [`Dist::optimize_wasm`]. Only available when the `wasm-opt` feature is enabled.
191    #[cfg(feature = "wasm-opt")]
192    #[clap(skip)]
193    pub wasm_opt: Option<crate::WasmOpt>,
194}
195
196impl Dist {
197    /// Set the command used by the build process.
198    ///
199    /// The default command is the result of the [`default_build_command`].
200    pub fn build_command(mut self, command: process::Command) -> Self {
201        self.build_command = command;
202        self
203    }
204
205    /// Set the directory for the generated artifacts.
206    ///
207    /// The default for debug build is `target/debug/dist` and
208    /// `target/release/dist` for the release build.
209    pub fn dist_dir(mut self, path: impl Into<PathBuf>) -> Self {
210        self.dist_dir = Some(path.into());
211        self
212    }
213
214    /// Set the directory for the static assets artifacts (like `index.html`).
215    ///
216    /// Default to `assets` in the package root when it exists.
217    pub fn assets_dir(mut self, path: impl Into<PathBuf>) -> Self {
218        self.assets_dir = Some(path.into());
219        self
220    }
221
222    /// Set the resulting package name.
223    ///
224    /// The default is `app`.
225    pub fn app_name(mut self, app_name: impl Into<String>) -> Self {
226        self.app_name = Some(app_name.into());
227        self
228    }
229
230    /// Add a transformer for the asset copy step.
231    ///
232    /// Transformers are called in the order they are added. See [`Transformer`] for details.
233    pub fn transformer(mut self, transformer: impl Transformer + 'static) -> Self {
234        self.transformers.push(Box::new(transformer));
235        self
236    }
237
238    /// Run [`WasmOpt`](crate::WasmOpt) on the generated Wasm binary after the bindgen step.
239    ///
240    /// This is the recommended way to integrate `wasm-opt`: it runs automatically at the
241    /// end of [`build`](Self::build) using the resolved output path, so you do not need to
242    /// wrap [`Dist`] in a custom struct or compute the path manually.
243    ///
244    /// The optimization is skipped for debug builds — it only runs when [`release`](Self::release)
245    /// is `true`. A `log::debug!` message is emitted when it is skipped.
246    ///
247    /// Requires the `wasm-opt` feature.
248    ///
249    /// # Examples
250    ///
251    /// ```rust,no_run
252    /// use xtask_wasm::{anyhow::Result, clap, WasmOpt};
253    ///
254    /// #[derive(clap::Parser)]
255    /// enum Opt {
256    ///     Dist(xtask_wasm::Dist),
257    /// }
258    ///
259    /// fn main() -> Result<()> {
260    ///     let opt: Opt = clap::Parser::parse();
261    ///
262    ///     match opt {
263    ///         Opt::Dist(dist) => {
264    ///             dist.optimize_wasm(WasmOpt::level(1).shrink(2))
265    ///                 .build("my-project")?;
266    ///         }
267    ///     }
268    ///
269    ///     Ok(())
270    /// }
271    /// ```
272    #[cfg(feature = "wasm-opt")]
273    #[cfg_attr(docsrs, doc(cfg(feature = "wasm-opt")))]
274    pub fn optimize_wasm(mut self, wasm_opt: crate::WasmOpt) -> Self {
275        self.wasm_opt = Some(wasm_opt);
276        self
277    }
278
279    /// Set the example to build.
280    pub fn example(mut self, example: impl Into<String>) -> Self {
281        self.example = Some(example.into());
282        self
283    }
284
285    /// Get the default dist directory for debug builds.
286    pub fn default_debug_dir() -> camino::Utf8PathBuf {
287        metadata().target_directory.join("debug").join("dist")
288    }
289
290    /// Get the default dist directory for release builds.
291    pub fn default_release_dir() -> camino::Utf8PathBuf {
292        metadata().target_directory.join("release").join("dist")
293    }
294
295    /// Build the given package for Wasm.
296    ///
297    /// This will generate JS bindings via [`wasm-bindgen`](https://docs.rs/wasm-bindgen/latest/wasm_bindgen/)
298    /// and copy files from a given assets directory if any to finally return
299    /// the path of the generated artifacts.
300    #[cfg_attr(
301        feature = "wasm-opt",
302        doc = "Wasm optimizations can be achieved using [`WasmOpt`](crate::WasmOpt) if the feature `wasm-opt` is enabled."
303    )]
304    #[cfg_attr(
305        not(feature = "wasm-opt"),
306        doc = "Wasm optimizations can be achieved using `WasmOpt` if the feature `wasm-opt` is enabled."
307    )]
308    pub fn build(self, package_name: &str) -> Result<PathBuf> {
309        log::trace!("Getting package's metadata");
310        let metadata = metadata();
311
312        let dist_dir = self.dist_dir.unwrap_or_else(|| {
313            if self.release {
314                Self::default_release_dir().into()
315            } else {
316                Self::default_debug_dir().into()
317            }
318        });
319
320        log::trace!("Initializing dist process");
321        let mut build_command = self.build_command;
322
323        build_command.current_dir(&metadata.workspace_root);
324
325        if self.quiet {
326            build_command.arg("--quiet");
327        }
328
329        if let Some(number) = self.jobs {
330            build_command.args(["--jobs", &number]);
331        }
332
333        if let Some(profile) = self.profile {
334            build_command.args(["--profile", &profile]);
335        }
336
337        if self.release {
338            build_command.arg("--release");
339        }
340
341        for feature in &self.features {
342            build_command.args(["--features", feature]);
343        }
344
345        if self.all_features {
346            build_command.arg("--all-features");
347        }
348
349        if self.no_default_features {
350            build_command.arg("--no-default-features");
351        }
352
353        if self.verbose {
354            build_command.arg("--verbose");
355        }
356
357        if let Some(color) = self.color {
358            build_command.args(["--color", &color]);
359        }
360
361        if self.frozen {
362            build_command.arg("--frozen");
363        }
364
365        if self.locked {
366            build_command.arg("--locked");
367        }
368
369        if self.offline {
370            build_command.arg("--offline");
371        }
372
373        if self.ignore_rust_version {
374            build_command.arg("--ignore-rust-version");
375        }
376
377        build_command.args(["--package", package_name]);
378
379        if let Some(example) = &self.example {
380            build_command.args(["--example", example]);
381        }
382
383        let build_dir = metadata
384            .target_directory
385            .join("wasm32-unknown-unknown")
386            .join(if self.release { "release" } else { "debug" });
387        let input_path = if let Some(example) = &self.example {
388            build_dir
389                .join("examples")
390                .join(example.replace('-', "_"))
391                .with_extension("wasm")
392        } else {
393            build_dir
394                .join(package_name.replace('-', "_"))
395                .with_extension("wasm")
396        };
397
398        if input_path.exists() {
399            log::trace!("Removing existing target directory");
400            fs::remove_file(&input_path).context("cannot remove existing target")?;
401        }
402
403        log::trace!("Spawning build process");
404        ensure!(
405            build_command
406                .status()
407                .context("could not start cargo")?
408                .success(),
409            "cargo command failed"
410        );
411
412        let app_name = self.app_name.unwrap_or_else(|| "app".to_string());
413
414        log::trace!("Generating Wasm output");
415        let mut output = Bindgen::new()
416            .omit_default_module_path(false)
417            .input_path(input_path)
418            .out_name(&app_name)
419            .web(true)
420            .expect("web have panic")
421            .debug(!self.release)
422            .generate_output()
423            .context("could not generate Wasm bindgen file")?;
424
425        if dist_dir.exists() {
426            log::trace!("Removing already existing dist directory");
427            fs::remove_dir_all(&dist_dir)?;
428        }
429
430        log::trace!("Writing outputs to dist directory");
431        output.emit(&dist_dir)?;
432
433        let assets_dir = if let Some(assets_dir) = self.assets_dir {
434            Some(assets_dir)
435        } else if let Some(package) = metadata.packages.iter().find(|p| p.name == package_name) {
436            let path = package
437                .manifest_path
438                .parent()
439                .context("package manifest has no parent directory")?
440                .join("assets")
441                .as_std_path()
442                .to_path_buf();
443            Some(path)
444        } else {
445            log::debug!(
446                "package `{package_name}` not found in workspace metadata, skipping assets"
447            );
448            None
449        };
450
451        match assets_dir {
452            Some(assets_dir) if assets_dir.exists() => {
453                log::trace!("Copying assets directory into dist directory");
454                copy_assets(&assets_dir, &dist_dir, &self.transformers)?;
455            }
456            Some(assets_dir) => {
457                log::debug!(
458                    "assets directory `{}` does not exist, skipping",
459                    assets_dir.display()
460                );
461            }
462            None => {}
463        }
464
465        #[cfg(feature = "wasm-opt")]
466        if let Some(wasm_opt) = self.wasm_opt {
467            if self.release {
468                let wasm_path = dist_dir.join(format!("{app_name}_bg.wasm"));
469                wasm_opt.optimize(&wasm_path)?;
470            } else {
471                log::debug!("skipping wasm-opt: not a release build");
472            }
473        }
474
475        log::info!("Successfully built in {}", dist_dir.display());
476
477        Ok(dist_dir)
478    }
479}
480
481impl Default for Dist {
482    fn default() -> Dist {
483        Dist {
484            quiet: Default::default(),
485            jobs: Default::default(),
486            profile: Default::default(),
487            release: Default::default(),
488            features: Default::default(),
489            all_features: Default::default(),
490            no_default_features: Default::default(),
491            verbose: Default::default(),
492            color: Default::default(),
493            frozen: Default::default(),
494            locked: Default::default(),
495            offline: Default::default(),
496            ignore_rust_version: Default::default(),
497            example: Default::default(),
498            build_command: default_build_command(),
499            dist_dir: Default::default(),
500            assets_dir: Default::default(),
501            app_name: Default::default(),
502            transformers: vec![],
503            #[cfg(feature = "wasm-opt")]
504            wasm_opt: None,
505        }
506    }
507}
508
509fn copy_assets(
510    assets_dir: &Path,
511    dist_dir: &Path,
512    transformers: &[Box<dyn Transformer>],
513) -> Result<()> {
514    let walker = walkdir::WalkDir::new(assets_dir);
515    for entry in walker {
516        let entry = entry
517            .with_context(|| format!("cannot walk into directory `{}`", assets_dir.display()))?;
518        let source = entry.path();
519        let dest = dist_dir.join(source.strip_prefix(assets_dir).unwrap());
520
521        if !source.is_file() {
522            continue;
523        }
524
525        if let Some(parent) = dest.parent() {
526            fs::create_dir_all(parent)
527                .with_context(|| format!("cannot create directory `{}`", parent.display()))?;
528        }
529
530        let mut handled = false;
531        for transformer in transformers {
532            if transformer.transform(source, &dest)? {
533                handled = true;
534                break;
535            }
536        }
537
538        if !handled {
539            fs::copy(source, &dest).with_context(|| {
540                format!("cannot copy `{}` to `{}`", source.display(), dest.display())
541            })?;
542        }
543    }
544
545    Ok(())
546}