Skip to main content

fission_command_site/
lib.rs

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}