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
9 use crate::{error::DeployError, report::Reporter};
10 use anyhow::anyhow;
11 use std::{
12 path::{Path, PathBuf},
13 process::Command,
14 };
15
16 /// What a build produced and where to ship it from.
17 pub 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`].
23 pub 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`).
29 pub struct ViteSpaBuild {
30 pub project_path: String,
31 pub output_dir: String,
32 pub package_manager: String,
33 }
34
35 impl 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)]
82 pub 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
93 impl 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.
132 pub struct SvelteKitBuild {
133 pub project_path: String,
134 pub output_dir: String,
135 pub package_manager: String,
136 }
137
138 impl 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
165 impl 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`.
245 fn 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)]
259 mod 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 }