use anyhow::{bail, Context, Result};
use std::ffi::OsStr;
use std::fs;
use std::io::{self, BufRead, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn build(project_dir: &Path, release: bool) -> Result<()> {
if site_entry_configured(project_dir)? {
return run_site_builder(project_dir, release, "build", &[]);
}
let options = site_build_options(project_dir)?;
let report = fission_shell_site::build_content_site(&options)?;
println!(
"Built {} static route(s) into {}",
report.routes.len(),
report.output_dir.display()
);
for route in report.routes {
println!("{} -> {}", route.path, route.output.display());
}
Ok(())
}
pub fn check(project_dir: &Path, release: bool) -> Result<()> {
if site_entry_configured(project_dir)? {
return run_site_builder(project_dir, release, "check", &[]);
}
let options = site_build_options(project_dir)?;
let report = fission_shell_site::check_content_site(&options)?;
println!(
"Checked {} static route(s); output would be {}",
report.routes.len(),
report.output_dir.display()
);
Ok(())
}
pub fn routes(project_dir: &Path) -> Result<()> {
if site_entry_configured(project_dir)? {
return run_site_builder(project_dir, false, "routes", &[]);
}
let options = site_build_options(project_dir)?;
let routes = fission_shell_site::list_content_routes(&options)?;
for route in routes {
println!(
"{} {} {}",
route.path,
route.title,
route.source.display()
);
}
Ok(())
}
pub fn serve(project_dir: &Path, release: bool, host: String, port: u16, open: bool) -> Result<()> {
if site_entry_configured(project_dir)? {
let port = port.to_string();
let open_flag = if open { "" } else { "--no-open" };
let mut args = vec!["--host", host.as_str(), "--port", port.as_str()];
if !open {
args.push(open_flag);
}
return run_site_builder(project_dir, release, "serve", &args);
}
build(project_dir, release)?;
let options = site_build_options(project_dir)?;
serve_static(options.output_dir, host, port, open)
}
pub fn serve_static(root: PathBuf, host: String, port: u16, open: bool) -> Result<()> {
let listener = TcpListener::bind((host.as_str(), port))
.with_context(|| format!("failed to bind {}:{}", host, port))?;
let url = if root.join("index.html").exists() {
format!("http://{host}:{port}/")
} else {
format!("http://{host}:{port}/platforms/web/")
};
println!("Serving {} at {}", root.display(), url);
println!("Press Ctrl+C to stop.");
if open {
let _ = open_url(&url);
}
for stream in listener.incoming() {
match stream {
Ok(stream) => {
if let Err(error) = handle_http_request(stream, &root) {
eprintln!("request failed: {error}");
}
}
Err(error) => eprintln!("accept failed: {error}"),
}
}
Ok(())
}
fn site_build_options(project_dir: &Path) -> Result<fission_shell_site::SiteBuildOptions> {
let app_name = project_name(project_dir)?;
fission_shell_site::SiteBuildOptions::from_project_dir(project_dir, app_name.clone()).or_else(
|_| {
Ok(fission_shell_site::SiteBuildOptions::for_project(
project_dir,
app_name,
))
},
)
}
fn project_name(project_dir: &Path) -> Result<String> {
let path = project_dir.join("fission.toml");
let data =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let value: toml::Value =
toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
value
.get("app")
.and_then(|app| app.get("name"))
.and_then(|name| name.as_str())
.map(ToString::to_string)
.context("fission.toml is missing app.name")
}
fn site_entry_configured(project_dir: &Path) -> Result<bool> {
let path = project_dir.join("fission.toml");
let data =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let value: toml::Value =
toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
Ok(value
.get("site")
.and_then(|site| site.get("entry"))
.and_then(|entry| entry.as_str())
.is_some())
}
fn run_site_builder(
project_dir: &Path,
release: bool,
command_name: &str,
extra_args: &[&str],
) -> Result<()> {
let manifest_path = project_dir.join("Cargo.toml");
if !manifest_path.exists() {
bail!(
"site entry is configured but {} is missing",
manifest_path.display()
);
}
let mut command = Command::new("cargo");
command
.arg("run")
.arg("--manifest-path")
.arg(&manifest_path);
if release {
command.arg("--release");
}
command
.arg("--")
.arg(command_name)
.arg("--project-dir")
.arg(project_dir);
for arg in extra_args {
if !arg.is_empty() {
command.arg(arg);
}
}
run_status(&mut command, "site builder")
}
fn run_status(command: &mut Command, label: &str) -> Result<()> {
let status = command
.status()
.with_context(|| format!("failed to run {label}"))?;
if !status.success() {
bail!("{label} failed with {status}");
}
Ok(())
}
fn handle_http_request(mut stream: TcpStream, root: &Path) -> Result<()> {
let mut reader = io::BufReader::new(stream.try_clone()?);
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
let mut request_parts = request_line.split_whitespace();
let method = request_parts.next().unwrap_or("GET");
let path = request_parts
.next()
.unwrap_or("/")
.split('?')
.next()
.unwrap_or("/");
if method == "POST" && path == "/__fission/renderer" {
let body = read_http_body(&mut reader)?;
println!("{}", format_renderer_diagnostic(&body));
stream.write_all(&http_response(204, "text/plain", b""))?;
return Ok(());
}
let response = static_response(root, path)?;
stream.write_all(&response)?;
Ok(())
}
fn format_renderer_diagnostic(body: &str) -> String {
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return format!("renderer diagnostics: {}", body.trim());
};
let active = value
.get("active")
.and_then(|value| value.as_str())
.unwrap_or("unknown");
let requested = value
.get("requested")
.and_then(|value| value.as_str())
.unwrap_or("unknown");
let backend = value
.get("backend")
.and_then(|value| value.as_str())
.map(|backend| format!(" backend={backend}"))
.unwrap_or_default();
let fallback = value
.get("fallback_reason")
.and_then(|value| value.as_str())
.map(|reason| format!(" fallback_reason={reason}"))
.unwrap_or_default();
let width = value
.get("width")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let height = value
.get("height")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let scale = value
.get("scale_factor")
.and_then(|value| value.as_f64())
.unwrap_or(1.0);
format!(
"renderer: {active} requested={requested}{backend} size={width}x{height} scale={scale:.2}{fallback}"
)
}
fn read_http_body(reader: &mut io::BufReader<TcpStream>) -> Result<String> {
let mut content_length = 0usize;
loop {
let mut line = String::new();
reader.read_line(&mut line)?;
let trimmed = line.trim_end();
if trimmed.is_empty() {
break;
}
if let Some(value) = trimmed
.strip_prefix("Content-Length:")
.or_else(|| trimmed.strip_prefix("content-length:"))
{
content_length = value.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; content_length.min(1024 * 1024)];
if !body.is_empty() {
use std::io::Read as _;
reader.read_exact(&mut body)?;
}
Ok(String::from_utf8_lossy(&body).into_owned())
}
fn static_response(root: &Path, request_path: &str) -> Result<Vec<u8>> {
let mut relative = request_path.trim_start_matches('/').to_string();
if relative.is_empty() {
relative = if root.join("index.html").exists() {
"index.html".to_string()
} else {
"platforms/web/".to_string()
};
}
if relative.ends_with('/') {
relative.push_str("index.html");
}
if !relative.ends_with(".html") && !relative.contains('.') {
relative.push_str("/index.html");
}
let path = sanitize_static_path(root, &relative)?;
if !path.exists() || !path.is_file() {
println!("GET {} 404", request_path);
return Ok(http_response(404, "text/plain", b"not found"));
}
let body = fs::read(&path)?;
let content_type = content_type(&path);
println!("GET {} 200", request_path);
Ok(http_response(200, content_type, &body))
}
fn sanitize_static_path(root: &Path, relative: &str) -> Result<PathBuf> {
let mut path = PathBuf::from(root);
for part in relative.split('/') {
if part.is_empty() || part == "." {
continue;
}
if part == ".." || part.contains('\\') {
bail!("invalid static path `{relative}`");
}
path.push(part);
}
Ok(path)
}
fn http_response(status: u16, content_type: &str, body: &[u8]) -> Vec<u8> {
let reason = match status {
200 => "OK",
404 => "Not Found",
_ => "Error",
};
let mut response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nContent-Type: {content_type}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
response.extend_from_slice(body);
response
}
fn content_type(path: &Path) -> &'static str {
match path.extension().and_then(OsStr::to_str).unwrap_or("") {
"html" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"wasm" => "application/wasm",
"json" => "application/json; charset=utf-8",
"png" => "image/png",
"svg" => "image/svg+xml",
"css" => "text/css; charset=utf-8",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::format_renderer_diagnostic;
#[test]
fn formats_renderer_diagnostic_as_cli_line() {
let body = r#"{
"active":"webgpu-vello",
"requested":"auto",
"backend":"BrowserWebGpu",
"width":1200,
"height":800,
"scale_factor":2.0
}"#;
assert_eq!(
format_renderer_diagnostic(body),
"renderer: webgpu-vello requested=auto backend=BrowserWebGpu size=1200x800 scale=2.00"
);
}
#[test]
fn keeps_fallback_reason_visible() {
let body = r#"{
"active":"canvas2d-software",
"requested":"auto",
"fallback_reason":"webgpu_vello_init_failed:no adapter",
"width":800,
"height":600,
"scale_factor":1.0
}"#;
assert!(format_renderer_diagnostic(body)
.contains("fallback_reason=webgpu_vello_init_failed:no adapter"));
}
}
fn open_url(url: &str) -> Result<()> {
let mut command = if cfg!(target_os = "macos") {
let mut cmd = Command::new("open");
cmd.arg(url);
cmd
} else if cfg!(target_os = "windows") {
let mut cmd = Command::new("cmd");
cmd.args(["/C", "start", "", url]);
cmd
} else {
let mut cmd = Command::new("xdg-open");
cmd.arg(url);
cmd
};
command.spawn()?;
Ok(())
}