1use anyhow::{bail, Context, Result};
2use std::ffi::OsStr;
3use std::fs;
4use std::io::{self, BufRead, Write};
5use std::net::{TcpListener, TcpStream};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9pub fn build(project_dir: &Path, release: bool) -> Result<()> {
10 if site_entry_configured(project_dir)? {
11 return run_site_builder(project_dir, release, "build", &[]);
12 }
13 let options = site_build_options(project_dir)?;
14 let report = fission_shell_site::build_content_site(&options)?;
15 println!(
16 "Built {} static route(s) into {}",
17 report.routes.len(),
18 report.output_dir.display()
19 );
20 for route in report.routes {
21 println!("{} -> {}", route.path, route.output.display());
22 }
23 Ok(())
24}
25
26pub fn check(project_dir: &Path, release: bool) -> Result<()> {
27 if site_entry_configured(project_dir)? {
28 return run_site_builder(project_dir, release, "check", &[]);
29 }
30 let options = site_build_options(project_dir)?;
31 let report = fission_shell_site::check_content_site(&options)?;
32 println!(
33 "Checked {} static route(s); output would be {}",
34 report.routes.len(),
35 report.output_dir.display()
36 );
37 Ok(())
38}
39
40pub fn routes(project_dir: &Path) -> Result<()> {
41 if site_entry_configured(project_dir)? {
42 return run_site_builder(project_dir, false, "routes", &[]);
43 }
44 let options = site_build_options(project_dir)?;
45 let routes = fission_shell_site::list_content_routes(&options)?;
46 for route in routes {
47 println!(
48 "{} {} {}",
49 route.path,
50 route.title,
51 route.source.display()
52 );
53 }
54 Ok(())
55}
56
57pub fn serve(project_dir: &Path, release: bool, host: String, port: u16, open: bool) -> Result<()> {
58 if site_entry_configured(project_dir)? {
59 let port = port.to_string();
60 let open_flag = if open { "" } else { "--no-open" };
61 let mut args = vec!["--host", host.as_str(), "--port", port.as_str()];
62 if !open {
63 args.push(open_flag);
64 }
65 return run_site_builder(project_dir, release, "serve", &args);
66 }
67 build(project_dir, release)?;
68 let options = site_build_options(project_dir)?;
69 serve_static(options.output_dir, host, port, open)
70}
71
72pub fn serve_static(root: PathBuf, host: String, port: u16, open: bool) -> Result<()> {
73 let listener = TcpListener::bind((host.as_str(), port))
74 .with_context(|| format!("failed to bind {}:{}", host, port))?;
75 let url = if root.join("index.html").exists() {
76 format!("http://{host}:{port}/")
77 } else {
78 format!("http://{host}:{port}/platforms/web/")
79 };
80 println!("Serving {} at {}", root.display(), url);
81 println!("Press Ctrl+C to stop.");
82 if open {
83 let _ = open_url(&url);
84 }
85 for stream in listener.incoming() {
86 match stream {
87 Ok(stream) => {
88 if let Err(error) = handle_http_request(stream, &root) {
89 eprintln!("request failed: {error}");
90 }
91 }
92 Err(error) => eprintln!("accept failed: {error}"),
93 }
94 }
95 Ok(())
96}
97
98fn site_build_options(project_dir: &Path) -> Result<fission_shell_site::SiteBuildOptions> {
99 let app_name = project_name(project_dir)?;
100 fission_shell_site::SiteBuildOptions::from_project_dir(project_dir, app_name.clone()).or_else(
101 |_| {
102 Ok(fission_shell_site::SiteBuildOptions::for_project(
103 project_dir,
104 app_name,
105 ))
106 },
107 )
108}
109
110fn project_name(project_dir: &Path) -> Result<String> {
111 let path = project_dir.join("fission.toml");
112 let data =
113 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
114 let value: toml::Value =
115 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
116 value
117 .get("app")
118 .and_then(|app| app.get("name"))
119 .and_then(|name| name.as_str())
120 .map(ToString::to_string)
121 .context("fission.toml is missing app.name")
122}
123
124fn site_entry_configured(project_dir: &Path) -> Result<bool> {
125 let path = project_dir.join("fission.toml");
126 let data =
127 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
128 let value: toml::Value =
129 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
130 Ok(value
131 .get("site")
132 .and_then(|site| site.get("entry"))
133 .and_then(|entry| entry.as_str())
134 .is_some())
135}
136
137fn run_site_builder(
138 project_dir: &Path,
139 release: bool,
140 command_name: &str,
141 extra_args: &[&str],
142) -> Result<()> {
143 let manifest_path = project_dir.join("Cargo.toml");
144 if !manifest_path.exists() {
145 bail!(
146 "site entry is configured but {} is missing",
147 manifest_path.display()
148 );
149 }
150 let mut command = Command::new("cargo");
151 command
152 .arg("run")
153 .arg("--manifest-path")
154 .arg(&manifest_path);
155 if release {
156 command.arg("--release");
157 }
158 command
159 .arg("--")
160 .arg(command_name)
161 .arg("--project-dir")
162 .arg(project_dir);
163 for arg in extra_args {
164 if !arg.is_empty() {
165 command.arg(arg);
166 }
167 }
168 run_status(&mut command, "site builder")
169}
170
171fn run_status(command: &mut Command, label: &str) -> Result<()> {
172 let status = command
173 .status()
174 .with_context(|| format!("failed to run {label}"))?;
175 if !status.success() {
176 bail!("{label} failed with {status}");
177 }
178 Ok(())
179}
180
181fn handle_http_request(mut stream: TcpStream, root: &Path) -> Result<()> {
182 let mut reader = io::BufReader::new(stream.try_clone()?);
183 let mut request_line = String::new();
184 reader.read_line(&mut request_line)?;
185 let mut request_parts = request_line.split_whitespace();
186 let method = request_parts.next().unwrap_or("GET");
187 let path = request_parts
188 .next()
189 .unwrap_or("/")
190 .split('?')
191 .next()
192 .unwrap_or("/");
193 if method == "POST" && path == "/__fission/renderer" {
194 let body = read_http_body(&mut reader)?;
195 println!("{}", format_renderer_diagnostic(&body));
196 stream.write_all(&http_response(204, "text/plain", b""))?;
197 return Ok(());
198 }
199 let response = static_response(root, path)?;
200 stream.write_all(&response)?;
201 Ok(())
202}
203
204fn format_renderer_diagnostic(body: &str) -> String {
205 let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
206 return format!("renderer diagnostics: {}", body.trim());
207 };
208 let active = value
209 .get("active")
210 .and_then(|value| value.as_str())
211 .unwrap_or("unknown");
212 let requested = value
213 .get("requested")
214 .and_then(|value| value.as_str())
215 .unwrap_or("unknown");
216 let backend = value
217 .get("backend")
218 .and_then(|value| value.as_str())
219 .map(|backend| format!(" backend={backend}"))
220 .unwrap_or_default();
221 let fallback = value
222 .get("fallback_reason")
223 .and_then(|value| value.as_str())
224 .map(|reason| format!(" fallback_reason={reason}"))
225 .unwrap_or_default();
226 let width = value
227 .get("width")
228 .and_then(|value| value.as_u64())
229 .unwrap_or(0);
230 let height = value
231 .get("height")
232 .and_then(|value| value.as_u64())
233 .unwrap_or(0);
234 let scale = value
235 .get("scale_factor")
236 .and_then(|value| value.as_f64())
237 .unwrap_or(1.0);
238 format!(
239 "renderer: {active} requested={requested}{backend} size={width}x{height} scale={scale:.2}{fallback}"
240 )
241}
242
243fn read_http_body(reader: &mut io::BufReader<TcpStream>) -> Result<String> {
244 let mut content_length = 0usize;
245 loop {
246 let mut line = String::new();
247 reader.read_line(&mut line)?;
248 let trimmed = line.trim_end();
249 if trimmed.is_empty() {
250 break;
251 }
252 if let Some(value) = trimmed
253 .strip_prefix("Content-Length:")
254 .or_else(|| trimmed.strip_prefix("content-length:"))
255 {
256 content_length = value.trim().parse().unwrap_or(0);
257 }
258 }
259 let mut body = vec![0u8; content_length.min(1024 * 1024)];
260 if !body.is_empty() {
261 use std::io::Read as _;
262 reader.read_exact(&mut body)?;
263 }
264 Ok(String::from_utf8_lossy(&body).into_owned())
265}
266
267fn static_response(root: &Path, request_path: &str) -> Result<Vec<u8>> {
268 let mut relative = request_path.trim_start_matches('/').to_string();
269 if relative.is_empty() {
270 relative = if root.join("index.html").exists() {
271 "index.html".to_string()
272 } else {
273 "platforms/web/".to_string()
274 };
275 }
276 if relative.ends_with('/') {
277 relative.push_str("index.html");
278 }
279 if !relative.ends_with(".html") && !relative.contains('.') {
280 relative.push_str("/index.html");
281 }
282 let path = sanitize_static_path(root, &relative)?;
283 if !path.exists() || !path.is_file() {
284 println!("GET {} 404", request_path);
285 return Ok(http_response(404, "text/plain", b"not found"));
286 }
287 let body = fs::read(&path)?;
288 let content_type = content_type(&path);
289 println!("GET {} 200", request_path);
290 Ok(http_response(200, content_type, &body))
291}
292
293fn sanitize_static_path(root: &Path, relative: &str) -> Result<PathBuf> {
294 let mut path = PathBuf::from(root);
295 for part in relative.split('/') {
296 if part.is_empty() || part == "." {
297 continue;
298 }
299 if part == ".." || part.contains('\\') {
300 bail!("invalid static path `{relative}`");
301 }
302 path.push(part);
303 }
304 Ok(path)
305}
306
307fn http_response(status: u16, content_type: &str, body: &[u8]) -> Vec<u8> {
308 let reason = match status {
309 200 => "OK",
310 404 => "Not Found",
311 _ => "Error",
312 };
313 let mut response = format!(
314 "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\nConnection: close\r\n\r\n",
315 body.len()
316 )
317 .into_bytes();
318 response.extend_from_slice(body);
319 response
320}
321
322fn content_type(path: &Path) -> &'static str {
323 match path.extension().and_then(OsStr::to_str).unwrap_or("") {
324 "html" => "text/html; charset=utf-8",
325 "js" | "mjs" => "text/javascript; charset=utf-8",
326 "wasm" => "application/wasm",
327 "json" => "application/json; charset=utf-8",
328 "png" => "image/png",
329 "svg" => "image/svg+xml",
330 "css" => "text/css; charset=utf-8",
331 _ => "application/octet-stream",
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::format_renderer_diagnostic;
338
339 #[test]
340 fn formats_renderer_diagnostic_as_cli_line() {
341 let body = r#"{
342 "active":"webgpu-vello",
343 "requested":"auto",
344 "backend":"BrowserWebGpu",
345 "width":1200,
346 "height":800,
347 "scale_factor":2.0
348 }"#;
349 assert_eq!(
350 format_renderer_diagnostic(body),
351 "renderer: webgpu-vello requested=auto backend=BrowserWebGpu size=1200x800 scale=2.00"
352 );
353 }
354
355 #[test]
356 fn keeps_fallback_reason_visible() {
357 let body = r#"{
358 "active":"canvas2d-software",
359 "requested":"auto",
360 "fallback_reason":"webgpu_vello_init_failed:no adapter",
361 "width":800,
362 "height":600,
363 "scale_factor":1.0
364 }"#;
365 assert!(format_renderer_diagnostic(body)
366 .contains("fallback_reason=webgpu_vello_init_failed:no adapter"));
367 }
368}
369
370fn open_url(url: &str) -> Result<()> {
371 let mut command = if cfg!(target_os = "macos") {
372 let mut cmd = Command::new("open");
373 cmd.arg(url);
374 cmd
375 } else if cfg!(target_os = "windows") {
376 let mut cmd = Command::new("cmd");
377 cmd.args(["/C", "start", "", url]);
378 cmd
379 } else {
380 let mut cmd = Command::new("xdg-open");
381 cmd.arg(url);
382 cmd
383 };
384 command.spawn()?;
385 Ok(())
386}