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        } else {
382            build_command.arg("--lib");
383        }
384
385        let build_dir = metadata
386            .target_directory
387            .join("wasm32-unknown-unknown")
388            .join(if self.release { "release" } else { "debug" });
389        let input_path = if let Some(example) = &self.example {
390            build_dir
391                .join("examples")
392                .join(example.replace('-', "_"))
393                .with_extension("wasm")
394        } else {
395            build_dir
396                .join(package_name.replace('-', "_"))
397                .with_extension("wasm")
398        };
399
400        if input_path.exists() {
401            log::trace!("Removing existing target directory");
402            fs::remove_file(&input_path).context("cannot remove existing target")?;
403        }
404
405        log::trace!("Spawning build process");
406        ensure!(
407            build_command
408                .status()
409                .context("could not start cargo")?
410                .success(),
411            "cargo command failed"
412        );
413
414        let app_name = self.app_name.unwrap_or_else(|| "app".to_string());
415
416        log::trace!("Generating Wasm output");
417        let mut output = Bindgen::new()
418            .omit_default_module_path(false)
419            .input_path(input_path)
420            .out_name(&app_name)
421            .web(true)
422            .expect("web have panic")
423            .debug(!self.release)
424            .generate_output()
425            .context("could not generate Wasm bindgen file")?;
426
427        if dist_dir.exists() {
428            log::trace!("Removing already existing dist directory");
429            fs::remove_dir_all(&dist_dir)?;
430        }
431
432        log::trace!("Writing outputs to dist directory");
433        output.emit(&dist_dir)?;
434
435        let assets_dir = if let Some(assets_dir) = self.assets_dir {
436            Some(assets_dir)
437        } else if let Some(package) = metadata.packages.iter().find(|p| p.name == package_name) {
438            let path = package
439                .manifest_path
440                .parent()
441                .context("package manifest has no parent directory")?
442                .join("assets")
443                .as_std_path()
444                .to_path_buf();
445            Some(path)
446        } else {
447            log::debug!(
448                "package `{package_name}` not found in workspace metadata, skipping assets"
449            );
450            None
451        };
452
453        match assets_dir {
454            Some(assets_dir) if assets_dir.exists() => {
455                log::trace!("Copying assets directory into dist directory");
456                copy_assets(&assets_dir, &dist_dir, &self.transformers)?;
457            }
458            Some(assets_dir) => {
459                log::debug!(
460                    "assets directory `{}` does not exist, skipping",
461                    assets_dir.display()
462                );
463            }
464            None => {}
465        }
466
467        #[cfg(feature = "wasm-opt")]
468        if let Some(wasm_opt) = self.wasm_opt {
469            if self.release {
470                let wasm_path = dist_dir.join(format!("{app_name}_bg.wasm"));
471                wasm_opt.optimize(&wasm_path)?;
472            } else {
473                log::debug!("skipping wasm-opt: not a release build");
474            }
475        }
476
477        log::info!("Successfully built in {}", dist_dir.display());
478
479        Ok(dist_dir)
480    }
481}
482
483impl Default for Dist {
484    fn default() -> Dist {
485        Dist {
486            quiet: Default::default(),
487            jobs: Default::default(),
488            profile: Default::default(),
489            release: Default::default(),
490            features: Default::default(),
491            all_features: Default::default(),
492            no_default_features: Default::default(),
493            verbose: Default::default(),
494            color: Default::default(),
495            frozen: Default::default(),
496            locked: Default::default(),
497            offline: Default::default(),
498            ignore_rust_version: Default::default(),
499            example: Default::default(),
500            build_command: default_build_command(),
501            dist_dir: Default::default(),
502            assets_dir: Default::default(),
503            app_name: Default::default(),
504            transformers: vec![],
505            #[cfg(feature = "wasm-opt")]
506            wasm_opt: None,
507        }
508    }
509}
510
511fn copy_assets(
512    assets_dir: &Path,
513    dist_dir: &Path,
514    transformers: &[Box<dyn Transformer>],
515) -> Result<()> {
516    let walker = walkdir::WalkDir::new(assets_dir);
517    for entry in walker {
518        let entry = entry
519            .with_context(|| format!("cannot walk into directory `{}`", assets_dir.display()))?;
520        let source = entry.path();
521        let dest = dist_dir.join(source.strip_prefix(assets_dir).unwrap());
522
523        if !source.is_file() {
524            continue;
525        }
526
527        if let Some(parent) = dest.parent() {
528            fs::create_dir_all(parent)
529                .with_context(|| format!("cannot create directory `{}`", parent.display()))?;
530        }
531
532        let mut handled = false;
533        for transformer in transformers {
534            if transformer.transform(source, &dest)? {
535                handled = true;
536                break;
537            }
538        }
539
540        if !handled {
541            fs::copy(source, &dest).with_context(|| {
542                format!("cannot copy `{}` to `{}`", source.display(), dest.display())
543            })?;
544        }
545    }
546
547    Ok(())
548}