Skip to main content

smbcloud_deploy/
build.rs

1//! Local build strategies.
2//!
3//! A [`BuildStrategy`] runs a project's build locally and reports where the
4//! shippable output is, as a [`BuildArtifact`]. Transport (see
5//! [`crate::transport`]) takes it from there. Keeping "build" and "ship"
6//! separate is what lets the same engine drive a local build, a CI build, or a
7//! server-side build without the strategies knowing which.
8
9use crate::{error::DeployError, report::Reporter};
10use anyhow::anyhow;
11use std::{
12    path::{Path, PathBuf},
13    process::Command,
14};
15
16/// What a build produced and where to ship it from.
17pub struct BuildArtifact {
18    /// Local directory whose contents should be shipped.
19    pub source_dir: PathBuf,
20}
21
22/// Builds a project locally into a [`BuildArtifact`].
23pub trait BuildStrategy {
24    fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError>;
25}
26
27/// A Vite / SPA build: run `<package_manager> build` in the project directory
28/// and ship its output directory (e.g. `dist`).
29pub struct ViteSpaBuild {
30    pub project_path: String,
31    pub output_dir: String,
32    pub package_manager: String,
33}
34
35impl BuildStrategy for ViteSpaBuild {
36    fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError> {
37        reporter.step_start(&format!(
38            "Building {} with {}…",
39            self.project_path, self.package_manager
40        ));
41
42        // Validate up front: `current_dir` on a missing path only surfaces as a
43        // confusing spawn error at status() time.
44        let project_dir = Path::new(&self.project_path);
45        if !project_dir.exists() {
46            reporter.step_fail(&format!(
47                "Project path '{}' does not exist.",
48                self.project_path
49            ));
50            return Err(DeployError::Other(anyhow!(
51                "Project path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
52                self.project_path
53            )));
54        }
55
56        let status = Command::new(&self.package_manager)
57            .arg("build")
58            .current_dir(&self.project_path)
59            .status()
60            .map_err(|e| {
61                reporter.step_fail(&format!("Failed to spawn '{}': {e}", self.package_manager));
62                DeployError::Other(anyhow!("Failed to spawn '{}': {e}", self.package_manager))
63            })?;
64
65        if !status.success() {
66            reporter.step_fail("Build failed. See output above.");
67            return Err(DeployError::Other(anyhow!(
68                "'{} build' exited with status {status}",
69                self.package_manager
70            )));
71        }
72
73        reporter.step_done("Build complete.");
74        Ok(BuildArtifact {
75            source_dir: project_dir.join(&self.output_dir),
76        })
77    }
78}
79
80/// Which SvelteKit adapter a project declares in its `package.json`.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum SvelteKitAdapter {
83    /// `@sveltejs/adapter-static`: prerendered pages plus an optional SPA
84    /// fallback, served as plain files by nginx.
85    Static,
86    /// Any other `@sveltejs/adapter-*` package, e.g. `adapter-node` or
87    /// `adapter-auto`.
88    Other(String),
89    /// `@sveltejs/kit` is present but no adapter package is.
90    Missing,
91}
92
93impl SvelteKitAdapter {
94    /// Reads the adapter from `package.json` contents. Returns `None` when the
95    /// manifest doesn't depend on `@sveltejs/kit` at all.
96    pub fn from_package_json(contents: &str) -> Result<Option<Self>, serde_json::Error> {
97        let manifest: serde_json::Value = serde_json::from_str(contents)?;
98        let dependency_names: Vec<&str> = ["dependencies", "devDependencies"]
99            .iter()
100            .filter_map(|section| manifest.get(section).and_then(|value| value.as_object()))
101            .flat_map(|section| section.keys().map(String::as_str))
102            .collect();
103
104        if !dependency_names.contains(&"@sveltejs/kit") {
105            return Ok(None);
106        }
107
108        let adapters: Vec<&str> = dependency_names
109            .iter()
110            .copied()
111            .filter(|name| name.starts_with("@sveltejs/adapter-"))
112            .collect();
113
114        // A project can list several adapters (e.g. one per target); static
115        // wins because it's the only one this strategy can ship.
116        if adapters.contains(&"@sveltejs/adapter-static") {
117            return Ok(Some(SvelteKitAdapter::Static));
118        }
119        Ok(Some(match adapters.first() {
120            Some(name) => SvelteKitAdapter::Other((*name).to_string()),
121            None => SvelteKitAdapter::Missing,
122        }))
123    }
124}
125
126/// A SvelteKit build using `@sveltejs/adapter-static`: install dependencies,
127/// run `<package_manager> run build`, and ship the adapter's output directory
128/// (`build` by default).
129///
130/// Server-rendered adapters are rejected up front. Their output needs a Node
131/// process on the server, which this static path doesn't manage.
132pub struct SvelteKitBuild {
133    pub project_path: String,
134    pub output_dir: String,
135    pub package_manager: String,
136}
137
138impl SvelteKitBuild {
139    fn fail(&self, reporter: &dyn Reporter, message: String) -> DeployError {
140        reporter.step_fail(&message);
141        DeployError::Other(anyhow!(message))
142    }
143
144    fn run(&self, reporter: &dyn Reporter, arguments: &[&str]) -> Result<(), DeployError> {
145        let display = format!("{} {}", self.package_manager, arguments.join(" "));
146        // Inherited stdio keeps the child on the user's terminal, so package
147        // manager prompts (e.g. pnpm's modules purge) can still be answered.
148        let status = Command::new(&self.package_manager)
149            .args(arguments)
150            .current_dir(&self.project_path)
151            .status()
152            .map_err(|error| {
153                self.fail(
154                    reporter,
155                    format!("Failed to spawn '{}': {error}", self.package_manager),
156                )
157            })?;
158        if !status.success() {
159            return Err(self.fail(reporter, format!("'{display}' exited with status {status}")));
160        }
161        Ok(())
162    }
163}
164
165impl BuildStrategy for SvelteKitBuild {
166    fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError> {
167        reporter.step_start(&format!(
168            "Building SvelteKit app {} with {}…",
169            self.project_path, self.package_manager
170        ));
171
172        let project_dir = Path::new(&self.project_path);
173        if !project_dir.exists() {
174            return Err(self.fail(
175                reporter,
176                format!(
177                    "Project path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
178                    self.project_path
179                ),
180            ));
181        }
182
183        let manifest_path = project_dir.join("package.json");
184        let manifest = std::fs::read_to_string(&manifest_path).map_err(|error| {
185            self.fail(
186                reporter,
187                format!("Could not read {}: {error}", manifest_path.display()),
188            )
189        })?;
190        let adapter = SvelteKitAdapter::from_package_json(&manifest).map_err(|error| {
191            self.fail(
192                reporter,
193                format!("Could not parse {}: {error}", manifest_path.display()),
194            )
195        })?;
196
197        match adapter {
198            Some(SvelteKitAdapter::Static) => {}
199            None => {
200                return Err(self.fail(
201                    reporter,
202                    format!(
203                        "'{}' does not depend on @sveltejs/kit. For a plain Svelte + Vite app, use kind = \"vite-spa\".",
204                        self.project_path
205                    ),
206                ))
207            }
208            Some(SvelteKitAdapter::Missing) => {
209                return Err(self.fail(
210                    reporter,
211                    "No SvelteKit adapter found. Add @sveltejs/adapter-static and use it in svelte.config.js.".to_string(),
212                ))
213            }
214            Some(SvelteKitAdapter::Other(name)) => {
215                return Err(self.fail(
216                    reporter,
217                    format!(
218                        "{name} is not supported yet. smbCloud deploys SvelteKit with @sveltejs/adapter-static; switch svelte.config.js to it."
219                    ),
220                ))
221            }
222        }
223
224        self.run(reporter, &["install"])?;
225        self.run(reporter, &["run", "build"])?;
226
227        let source_dir = project_dir.join(&self.output_dir);
228        if !has_html_entry(&source_dir) {
229            return Err(self.fail(
230                reporter,
231                format!(
232                    "{} has no HTML entry point. Prerender at least one page, or set `fallback` in adapter-static for a single-page app.",
233                    source_dir.display()
234                ),
235            ));
236        }
237
238        reporter.step_done("Build complete.");
239        Ok(BuildArtifact { source_dir })
240    }
241}
242
243/// Whether `directory` holds at least one top-level `.html` file: a
244/// prerendered `index.html` or an adapter-static fallback such as `200.html`.
245fn has_html_entry(directory: &Path) -> bool {
246    let Ok(entries) = std::fs::read_dir(directory) else {
247        return false;
248    };
249    entries.flatten().any(|entry| {
250        let path: PathBuf = entry.path();
251        path.is_file()
252            && path
253                .extension()
254                .is_some_and(|extension| extension == "html")
255    })
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn detects_static_adapter_in_dev_dependencies() {
264        let manifest =
265            r#"{"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-static":"^3"}}"#;
266        assert_eq!(
267            SvelteKitAdapter::from_package_json(manifest).unwrap(),
268            Some(SvelteKitAdapter::Static)
269        );
270    }
271
272    #[test]
273    fn prefers_static_when_several_adapters_are_listed() {
274        let manifest = r#"{"dependencies":{"@sveltejs/adapter-node":"^5"},"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-static":"^3"}}"#;
275        assert_eq!(
276            SvelteKitAdapter::from_package_json(manifest).unwrap(),
277            Some(SvelteKitAdapter::Static)
278        );
279    }
280
281    #[test]
282    fn reports_other_adapters_by_name() {
283        let manifest =
284            r#"{"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-auto":"^3"}}"#;
285        assert_eq!(
286            SvelteKitAdapter::from_package_json(manifest).unwrap(),
287            Some(SvelteKitAdapter::Other(
288                "@sveltejs/adapter-auto".to_string()
289            ))
290        );
291    }
292
293    #[test]
294    fn reports_missing_adapter() {
295        let manifest = r#"{"devDependencies":{"@sveltejs/kit":"^2"}}"#;
296        assert_eq!(
297            SvelteKitAdapter::from_package_json(manifest).unwrap(),
298            Some(SvelteKitAdapter::Missing)
299        );
300    }
301
302    #[test]
303    fn plain_svelte_is_not_sveltekit() {
304        let manifest = r#"{"devDependencies":{"svelte":"^5","vite":"^5"}}"#;
305        assert_eq!(SvelteKitAdapter::from_package_json(manifest).unwrap(), None);
306    }
307
308    #[test]
309    fn invalid_manifest_is_an_error() {
310        assert!(SvelteKitAdapter::from_package_json("{").is_err());
311    }
312
313    #[test]
314    fn html_entry_accepts_spa_fallback() {
315        let directory = tempfile::tempdir().unwrap();
316        std::fs::write(directory.path().join("200.html"), "<html></html>").unwrap();
317        assert!(has_html_entry(directory.path()));
318    }
319
320    #[test]
321    fn html_entry_rejects_missing_or_asset_only_output() {
322        let directory = tempfile::tempdir().unwrap();
323        assert!(!has_html_entry(&directory.path().join("build")));
324        std::fs::create_dir(directory.path().join("_app")).unwrap();
325        std::fs::write(directory.path().join("robots.txt"), "").unwrap();
326        assert!(!has_html_entry(directory.path()));
327    }
328}