perseus_cli/
export.rs

1use crate::cmd::{cfg_spinner, run_stage};
2use crate::install::Tools;
3use crate::parse::{ExportOpts, Opts};
4use crate::thread::{spawn_thread, ThreadHandle};
5use crate::{errors::*, get_user_crate_name};
6use console::{style, Emoji};
7use indicatif::{MultiProgress, ProgressBar};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11// Emojis for stages
12static EXPORTING: Emoji<'_, '_> = Emoji("📦", "");
13static BUILDING: Emoji<'_, '_> = Emoji("🏗️ ", ""); // Yes, there's a space here, for some reason it's needed...
14
15/// Returns the exit code if it's non-zero.
16macro_rules! handle_exit_code {
17    ($code:expr) => {
18        let (_, _, code) = $code;
19        if code != 0 {
20            return ::std::result::Result::Ok(code);
21        }
22    };
23}
24
25/// An internal macro for symlinking/copying files into the export package. The
26/// `from` and `to` that this accepts should be extensions of the `target`, and
27/// they'll be `.join()`ed on.
28///
29/// This will attempt to symlink, and, if that fails, it will print a warning
30/// and copy normally.
31///
32/// For symlinking to work on Windows, developer mode must be enabled.
33macro_rules! copy_file {
34    ($from:expr, $to:expr, $target:expr) => {
35        // Try symlinking first
36        #[cfg(unix)]
37        if std::os::unix::fs::symlink($target.join($from), $target.join($to)).is_err() {
38            // That failed, try a usual copy
39            if let Err(err) = fs::copy($target.join($from), $target.join($to)) {
40                return Err(ExportError::MoveAssetFailed {
41                    to: $to.to_string(),
42                    from: $from.to_string(),
43                    source: err,
44                });
45            }
46        }
47        #[cfg(windows)]
48        if std::os::windows::fs::symlink_file($target.join($from), $target.join($to)).is_err() {
49            // That failed, try a usual copy
50            if let Err(err) = fs::copy($target.join($from), $target.join($to)) {
51                return Err(ExportError::MoveAssetFailed {
52                    to: $to.to_string(),
53                    from: $from.to_string(),
54                    source: err,
55                });
56            }
57        }
58    };
59}
60
61/// An internal macro for symlinking/copying directories into the export
62/// package. The `from` and `to` that this accepts should be extensions of the
63/// `target`, and they'll be `.join()`ed on.
64///
65/// This will attempt to symlink, and, if that fails, it will print a warning
66/// and copy normally.
67///
68/// For symlinking to work on Windows, developer mode must be enabled.
69macro_rules! copy_directory {
70    ($from:expr, $to:expr, $to_symlink:expr, $target:expr) => {
71        // Try symlinking first
72        #[cfg(unix)]
73        if std::os::unix::fs::symlink($target.join($from), $target.join($to_symlink)).is_err() {
74            // That failed, try a usual copy
75            if let Err(err) = fs_extra::dir::copy(
76                $target.join($from),
77                $target.join($to),
78                &fs_extra::dir::CopyOptions::new(),
79            ) {
80                return Err(ExportError::MoveDirFailed {
81                    to: $to.to_string(),
82                    from: $from.to_string(),
83                    source: err,
84                });
85            }
86        }
87        #[cfg(windows)]
88        if std::os::windows::fs::symlink_dir($target.join($from), $target.join($to_symlink))
89            .is_err()
90        {
91            // That failed, try a usual copy
92            if let Err(err) = fs_extra::dir::copy(
93                $target.join($from),
94                $target.join($to),
95                &fs_extra::dir::CopyOptions::new(),
96            ) {
97                return Err(ExportError::MoveDirFailed {
98                    to: $to.to_string(),
99                    from: $from.to_string(),
100                    source: err,
101                });
102            }
103        }
104    };
105}
106
107/// Finalizes the export by copying assets. This is very different from the
108/// finalization process of normal building.
109pub fn finalize_export(target: &Path) -> Result<(), ExportError> {
110    // Copy files over (the directory structure should already exist from exporting
111    // the pages)
112    copy_file!(
113        "dist/pkg/perseus_engine.js",
114        "dist/exported/.perseus/bundle.js",
115        target
116    );
117    copy_file!(
118        "dist/pkg/perseus_engine_bg.wasm",
119        "dist/exported/.perseus/bundle.wasm",
120        target
121    );
122    // Copy any JS snippets over (if the directory doesn't exist though, don't do
123    // anything)
124    if fs::metadata(target.join("dist/pkg/snippets")).is_ok() {
125        copy_directory!(
126            "dist/pkg/snippets",
127            "dist/exported/.perseus",          // For a usual copy
128            "dist/exported/.perseus/snippets", // For a symlink
129            target
130        );
131    }
132
133    Ok(())
134}
135
136/// Actually exports the user's code, program arguments having been interpreted.
137/// This needs to know how many steps there are in total because the serving
138/// logic also uses it. This also takes a `MultiProgress` to interact with so it
139/// can be used truly atomically. This returns handles for waiting on the
140/// component threads so we can use it composably.
141#[allow(clippy::type_complexity)]
142pub fn export_internal(
143    dir: PathBuf,
144    spinners: &MultiProgress,
145    num_steps: u8,
146    is_release: bool,
147    tools: &Tools,
148    global_opts: &Opts,
149) -> Result<
150    (
151        ThreadHandle<impl FnOnce() -> Result<i32, ExportError>, Result<i32, ExportError>>,
152        ThreadHandle<impl FnOnce() -> Result<i32, ExportError>, Result<i32, ExportError>>,
153    ),
154    ExportError,
155> {
156    let tools = tools.clone();
157    let Opts {
158        cargo_browser_args,
159        cargo_engine_args,
160        wasm_bindgen_args,
161        wasm_opt_args,
162        verbose,
163        mut wasm_release_rustflags,
164        ..
165    } = global_opts.clone();
166    let crate_name = get_user_crate_name(&dir)?;
167    wasm_release_rustflags.push_str(" --cfg=client");
168
169    // Exporting pages message
170    let ep_msg = format!(
171        "{} {} Exporting your app's pages",
172        style(format!("[1/{}]", num_steps)).bold().dim(),
173        EXPORTING
174    );
175    // Wasm building message
176    let wb_msg = format!(
177        "{} {} Building your app to Wasm",
178        style(format!("[2/{}]", num_steps)).bold().dim(),
179        BUILDING
180    );
181
182    // We parallelize the first two spinners (static generation and Wasm building)
183    // We make sure to add them at the top (the server spinner may have already been
184    // instantiated)
185    let ep_spinner = spinners.insert(0, ProgressBar::new_spinner());
186    let ep_spinner = cfg_spinner(ep_spinner, &ep_msg);
187    let ep_target = dir.clone();
188    let wb_spinner = spinners.insert(1, ProgressBar::new_spinner());
189    let wb_spinner = cfg_spinner(wb_spinner, &wb_msg);
190    let wb_target = dir;
191    let cargo_engine_exec = tools.cargo_engine.clone();
192    let ep_thread = spawn_thread(
193        move || {
194            handle_exit_code!(run_stage(
195                vec![&format!(
196                    "{} run {} {}",
197                    cargo_engine_exec,
198                    if is_release { "--release" } else { "" },
199                    cargo_engine_args
200                )],
201                &ep_target,
202                &ep_spinner,
203                &ep_msg,
204                vec![
205                    ("PERSEUS_ENGINE_OPERATION", "export"),
206                    ("CARGO_TARGET_DIR", "dist/target_engine"),
207                    ("RUSTFLAGS", "--cfg=engine"),
208                    ("CARGO_TERM_COLOR", "always")
209                ],
210                verbose,
211            )?);
212
213            Ok(0)
214        },
215        global_opts.sequential,
216    );
217    let wb_thread = spawn_thread(
218        move || {
219            let mut cmds = vec![
220            // Build the Wasm artifact first (and we know where it will end up, since we're setting the target directory)
221            format!(
222                "{} build --target wasm32-unknown-unknown {} {}",
223                tools.cargo_browser,
224                if is_release { "--release" } else { "" },
225                cargo_browser_args
226            ),
227            // NOTE The `wasm-bindgen` version has to be *identical* to the dependency version
228            format!(
229                "{cmd} ./dist/target_wasm/wasm32-unknown-unknown/{profile}/{crate_name}.wasm --out-dir dist/pkg --out-name perseus_engine --target web {args}",
230                cmd=tools.wasm_bindgen,
231                profile={ if is_release { "release" } else { "debug" } },
232                args=wasm_bindgen_args,
233                crate_name=crate_name
234            )
235        ];
236            // If we're building for release, then we should run `wasm-opt`
237            if is_release {
238                cmds.push(format!(
239                "{cmd} -Oz ./dist/pkg/perseus_engine_bg.wasm -o ./dist/pkg/perseus_engine_bg.wasm {args}",
240                cmd=tools.wasm_opt,
241                args=wasm_opt_args
242            ));
243            }
244            let cmds = cmds.iter().map(|s| s.as_str()).collect::<Vec<&str>>();
245            handle_exit_code!(run_stage(
246                cmds,
247                &wb_target,
248                &wb_spinner,
249                &wb_msg,
250                if is_release {
251                    vec![
252                        ("CARGO_TARGET_DIR", "dist/target_wasm"),
253                        ("RUSTFLAGS", &wasm_release_rustflags),
254                        ("CARGO_TERM_COLOR", "always"),
255                    ]
256                } else {
257                    vec![
258                        ("CARGO_TARGET_DIR", "dist/target_wasm"),
259                        ("RUSTFLAGS", "--cfg=client"),
260                        ("CARGO_TERM_COLOR", "always"),
261                    ]
262                },
263                verbose,
264            )?);
265
266            Ok(0)
267        },
268        global_opts.sequential,
269    );
270
271    Ok((ep_thread, wb_thread))
272}
273
274/// Builds the subcrates to get a directory that we can serve. Returns an exit
275/// code.
276pub fn export(
277    dir: PathBuf,
278    opts: &ExportOpts,
279    tools: &Tools,
280    global_opts: &Opts,
281) -> Result<i32, ExportError> {
282    let spinners = MultiProgress::new();
283    // We'll add another not-quite-spinner if we're serving
284    let num_spinners = if opts.serve { 3 } else { 2 };
285
286    let (ep_thread, wb_thread) = export_internal(
287        dir.clone(),
288        &spinners,
289        num_spinners,
290        opts.release,
291        tools,
292        global_opts,
293    )?;
294    let ep_res = ep_thread
295        .join()
296        .map_err(|_| ExecutionError::ThreadWaitFailed)??;
297    if ep_res != 0 {
298        return Ok(ep_res);
299    }
300    let wb_res = wb_thread
301        .join()
302        .map_err(|_| ExecutionError::ThreadWaitFailed)??;
303    if wb_res != 0 {
304        return Ok(wb_res);
305    }
306
307    // And now we can run the finalization stage
308    finalize_export(&dir)?;
309
310    // We've handled errors in the component threads, so the exit code is now zero
311    Ok(0)
312}