static_files/mods/
npm_build.rs

1/*!
2`npm` support.
3*/
4use std::{
5    env, fs,
6    io::{self},
7    path::{Path, PathBuf},
8    process::{Command, Stdio},
9};
10
11use super::resource_dir::ResourceDir;
12
13#[cfg(not(windows))]
14const NPM_CMD: &str = "npm";
15
16#[cfg(windows)]
17const NPM_CMD: &str = "npm.cmd";
18
19/// Generate resources with run of `npm install` prior to collecting
20/// resources in `resource_dir`.
21///
22/// Resources collected in `node_modules` subdirectory.
23pub fn npm_resource_dir<P: AsRef<Path>>(resource_dir: P) -> io::Result<ResourceDir> {
24    #[allow(unused_mut)]
25    let mut npm_build = NpmBuild::new(resource_dir)
26        .node_modules_strategy(NodeModulesStrategy::MoveToOutDir)
27        .install()?;
28
29    #[cfg(feature = "change-detection")]
30    {
31        npm_build = npm_build.change_detection();
32    }
33
34    Ok(npm_build.into())
35}
36
37/// Executes `npm` commands before collecting resources.
38///
39/// Example usage:
40/// Add `build.rs` with call to bundle resources:
41///
42/// ```rust, no_run
43/// use static_files::NpmBuild;
44///
45/// fn main() {
46///     NpmBuild::new("./web")
47///         .install().unwrap() // runs npm install
48///         .run("build").unwrap() // runs npm run build
49///         .target("./web/dist")
50///         .to_resource_dir()
51///         .build().unwrap();
52/// }
53/// ```
54/// Include generated code in `main.rs`:
55///
56/// ```rust, ignore
57/// include!(concat!(env!("OUT_DIR"), "/generated.rs"));
58/// ```
59#[derive(Default, Debug)]
60pub struct NpmBuild {
61    package_json_dir: PathBuf,
62    executable: String,
63    target_dir: Option<PathBuf>,
64    node_modules_strategy: NodeModulesStrategy,
65    stderr: Option<Stdio>,
66    stdout: Option<Stdio>,
67}
68
69impl NpmBuild {
70    pub fn new<P: AsRef<Path>>(package_json_dir: P) -> Self {
71        Self {
72            package_json_dir: package_json_dir.as_ref().into(),
73            executable: String::from(NPM_CMD),
74            ..Default::default()
75        }
76    }
77
78    /// Allow the user to set their own npm-like executable (like yarn, for instance)
79    #[must_use]
80    pub fn executable(self, executable: &str) -> Self {
81        let executable = String::from(executable);
82        Self { executable, ..self }
83    }
84
85    /// Generates change detection instructions.
86    ///
87    /// It includes `package.json` directory, ignores by default `node_modules`, `package.json` and `package-lock.json` and target directory.
88    /// Each time `npm` changes timestamps on these files, so if we do not ignore them - it runs `npm` each time.
89    /// It is recommended to put your dist files one level deeper. For example, if you have `web` with `package.json`
90    /// and `dist` just below that, you better generate you index.html somewhere in `web\dist\sub_path\index.html`.
91    /// Reason is the same, `npm` touches `dist` each time and it touches the parent directory which in its turn triggers the build each time.
92    /// For complete example see: [Angular Router Sample](https://github.com/kilork/actix-web-static-files-example-angular-router).
93    /// If default behavior does not work for you, you can use [change-detection](https://crates.io/crates/change-detection) directly.
94    #[cfg(feature = "change-detection")]
95    #[must_use]
96    #[allow(clippy::missing_panics_doc)]
97    pub fn change_detection(self) -> Self {
98        use ::change_detection::{
99            path_matchers::{any, equal, func, starts_with, PathMatcherExt},
100            ChangeDetection,
101        };
102
103        let package_json_dir = self.package_json_dir.clone();
104        let default_exclude_filter = any!(
105            equal(package_json_dir.clone()),
106            starts_with(self.package_json_dir.join("node_modules")),
107            equal(self.package_json_dir.join("package.json")),
108            equal(self.package_json_dir.join("package-lock.json")),
109            func(move |p| { p.is_file() && p.parent() != Some(package_json_dir.as_path()) })
110        );
111
112        {
113            // TODO: rework this code to not panic
114            let change_detection = if self.target_dir.is_none() {
115                ChangeDetection::exclude(default_exclude_filter)
116            } else {
117                let mut target_dir = self.target_dir.clone().unwrap();
118
119                if let Some(target_dir_parent) = target_dir.parent() {
120                    if target_dir_parent.starts_with(&self.package_json_dir) {
121                        while target_dir.parent() != Some(&self.package_json_dir) {
122                            target_dir = target_dir.parent().unwrap().into();
123                        }
124                    }
125                }
126
127                let exclude_filter = default_exclude_filter.or(starts_with(target_dir));
128                ChangeDetection::exclude(exclude_filter)
129            };
130
131            change_detection.path(&self.package_json_dir).generate();
132        }
133        self
134    }
135
136    /// Executes `npm install`.
137    pub fn install(mut self) -> io::Result<Self> {
138        self.package_command()
139            .arg("install")
140            .status()
141            .map_err(|err| {
142                eprintln!("Cannot execute {} install: {err:?}", self.executable);
143                err
144            })
145            .map(|_| self)
146    }
147
148    /// Executes `npm run CMD`.
149    pub fn run(mut self, cmd: &str) -> io::Result<Self> {
150        self.package_command()
151            .arg("run")
152            .arg(cmd)
153            .status()
154            .map_err(|err| {
155                eprintln!("Cannot execute {} run {cmd}: {err:?}", self.executable);
156                err
157            })
158            .map(|_| self)
159    }
160
161    /// Sets target (default is `node_modules`).
162    ///
163    /// The `OUT_DIR` variable is automatically prepended.
164    /// Do not forget to adjust your JS side accordingly.
165    /// Use absolute path to avoid this behavior.
166    #[must_use]
167    pub fn target<P: AsRef<Path>>(mut self, target_dir: P) -> Self {
168        let target_dir = target_dir.as_ref();
169        self.target_dir = Some(if target_dir.is_absolute() {
170            target_dir.into()
171        } else if let Ok(out_dir) = env::var("OUT_DIR").map(PathBuf::from) {
172            out_dir.join(target_dir)
173        } else {
174            target_dir.into()
175        });
176        self
177    }
178
179    /// Sets stderr for the next command.
180    ///
181    /// You should set it again, if you need also redirect output for the next command.
182    #[must_use]
183    pub fn stderr<S: Into<Stdio>>(mut self, stdio: S) -> Self {
184        self.stderr = Some(stdio.into());
185        self
186    }
187
188    /// Sets stdout for the next command.
189    ///
190    /// You should set it again, if you need also redirect output for the next command.
191    #[must_use]
192    pub fn stdout<S: Into<Stdio>>(mut self, stdio: S) -> Self {
193        self.stdout = Some(stdio.into());
194        self
195    }
196
197    /// Sets the strategy to executed upon building the `ResourceDir`.
198    ///
199    /// Default behavior is to clean `node_modules` directory.
200    #[must_use]
201    pub fn node_modules_strategy(mut self, node_modules_strategy: NodeModulesStrategy) -> Self {
202        self.node_modules_strategy = node_modules_strategy;
203        self
204    }
205
206    /// Converts to `ResourceDir`.
207    #[allow(clippy::wrong_self_convention)]
208    #[must_use]
209    pub fn to_resource_dir(self) -> ResourceDir {
210        self.into()
211    }
212
213    #[cfg(not(windows))]
214    fn command(&self) -> Command {
215        Command::new(&self.executable)
216    }
217
218    #[cfg(windows)]
219    fn command(&self) -> Command {
220        let mut cmd = Command::new("cmd");
221
222        cmd.arg("/c").arg(&self.executable);
223
224        cmd
225    }
226
227    fn package_command(&mut self) -> Command {
228        let mut cmd = self.command();
229
230        cmd.stderr(self.stderr.take().unwrap_or_else(Stdio::inherit))
231            .stdout(self.stdout.take().unwrap_or_else(Stdio::inherit))
232            .current_dir(&self.package_json_dir);
233
234        cmd
235    }
236
237    fn to_node_modules_dir(&self) -> PathBuf {
238        self.package_json_dir.join("node_modules")
239    }
240
241    fn remove_node_modules(&self) -> io::Result<()> {
242        let node_modules_dir = self.to_node_modules_dir();
243
244        if node_modules_dir.is_dir() {
245            fs::remove_dir_all(node_modules_dir)?;
246        }
247
248        Ok(())
249    }
250
251    fn move_node_modules_to_out_dir(&self) -> io::Result<PathBuf> {
252        let node_modules_dir = self.to_node_modules_dir();
253
254        if !node_modules_dir.is_dir() {
255            return Ok(node_modules_dir);
256        }
257
258        let Ok(out_node_modules_dir) =
259            env::var("OUT_DIR").map(|out_dir| PathBuf::from(out_dir).join("node_modules"))
260        else {
261            return Ok(node_modules_dir);
262        };
263
264        if out_node_modules_dir.is_dir() {
265            fs::remove_dir_all(&out_node_modules_dir)?;
266        }
267
268        copy_dir_all(&node_modules_dir, &out_node_modules_dir)?;
269        fs::remove_dir_all(node_modules_dir)?;
270
271        Ok(out_node_modules_dir)
272    }
273}
274
275impl From<NpmBuild> for ResourceDir {
276    fn from(mut npm_build: NpmBuild) -> Self {
277        let out_node_modules_dir = npm_build.node_modules_strategy.execute(&npm_build);
278
279        let resource_dir = npm_build
280            .target_dir
281            .take()
282            .or(out_node_modules_dir)
283            .unwrap_or_else(|| npm_build.to_node_modules_dir());
284
285        Self {
286            resource_dir,
287            ..Default::default()
288        }
289    }
290}
291
292#[derive(Default, Debug)]
293pub enum NodeModulesStrategy {
294    #[default]
295    Clean,
296    MoveToOutDir,
297}
298
299impl NodeModulesStrategy {
300    fn execute(&self, npm_build: &NpmBuild) -> Option<PathBuf> {
301        match self {
302            Self::Clean => {
303                npm_build
304                    .remove_node_modules()
305                    .expect("remove node_modules dir");
306                None
307            }
308            Self::MoveToOutDir => Some(
309                npm_build
310                    .move_node_modules_to_out_dir()
311                    .expect("move node_modules to out dir"),
312            ),
313        }
314    }
315}
316
317fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
318    fs::create_dir_all(&dst)?;
319    for entry in fs::read_dir(src)? {
320        let entry = entry?;
321        let ty = entry.file_type()?;
322        if ty.is_dir() {
323            copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
324        } else {
325            fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
326        }
327    }
328    Ok(())
329}