@smbcloud / cli / commits / 558bb4d

Add a SvelteKit static build strategy to the deploy engine

SvelteKitBuild reads the adapter from package.json. It only accepts @sveltejs/adapter-static and names the adapter it found otherwise, then installs dependencies, runs the build script, and checks the output has an HTML entry (a prerendered index.html or an SPA fallback like 200.html) before handing it to transport. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

keypair34 committed Sep 30, 2026 at 14:10 UTC 558bb4d76d2b248c1968f4648714efce3c8fd05c
4 files changed +253 -1
Cargo.lock
+1
@@ -2624,6 +2624,7 @@ name = "smbcloud-deploy"
2624 version = "0.5.4"
2625 dependencies = [
2626 "anyhow",
2627 + "serde_json",
2628 "smbcloud-model",
2629 "smbcloud-utils",
2630 "tempfile",
crates/smbcloud-deploy/Cargo.toml
+1
@@ -14,6 +14,7 @@ categories = ["development-tools", "development-tools::build-utils"]
14
15 [dependencies]
16 anyhow = { workspace = true }
17 +serde_json = { workspace = true }
18 tempfile = { workspace = true }
19 thiserror = { workspace = true }
20 tracing = { workspace = true }
crates/smbcloud-deploy/src/build.rs
+250
@@ -76,3 +76,253 @@ impl BuildStrategy for ViteSpaBuild {
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 +}
crates/smbcloud-deploy/src/lib.rs
+1 -1
@@ -22,7 +22,7 @@ pub mod report;
22 pub mod runner;
23 pub mod transport;
24
25 -pub use build::{BuildArtifact, BuildStrategy, ViteSpaBuild};
25 +pub use build::{BuildArtifact, BuildStrategy, SvelteKitAdapter, SvelteKitBuild, ViteSpaBuild};
26 pub use error::DeployError;
27 pub use report::{NoopReporter, Reporter};
28 pub use runner::detect_runner;