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}