use anyhow::{Context, Result};
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;
#[derive(Debug)]
pub struct BuildOptions {
pub project_dir: PathBuf,
pub remote_host: Option<String>,
}
pub async fn run_build(options: BuildOptions) -> Result<()> {
let project_dir = options.project_dir.canonicalize()?;
if !project_dir.join("Forte.toml").exists() {
anyhow::bail!("Not a Forte project (Forte.toml not found)");
}
println!("Building Forte project for production...");
println!("Project directory: {}", project_dir.display());
run_codegen(&project_dir).await?;
if let Some(ref remote_host) = options.remote_host {
build_backend_remote(&project_dir, remote_host)?;
} else {
build_backend(&project_dir)?;
}
build_frontend(&project_dir)?;
let dist_dir = project_dir.join("dist");
create_dist(&project_dir, &dist_dir)?;
println!();
println!("Build complete!");
println!("Output: {}", dist_dir.display());
Ok(())
}
const FORTE_RS_TO_TS_VERSION: &str = "0.1.4";
async fn ensure_forte_rs_to_ts() -> Result<PathBuf> {
let url = crate::tools::fn0_release_url("forte-rs-to-ts", FORTE_RS_TO_TS_VERSION)?;
crate::tools::ensure_github_tool_with_libs(
"forte-rs-to-ts",
FORTE_RS_TO_TS_VERSION,
&url,
"forte-rs-to-ts",
)
.await
}
async fn run_codegen(project_dir: &Path) -> Result<()> {
let rs_dir = project_dir.join("rs");
if !rs_dir.exists() {
generate_frontend_routes(project_dir)?;
return Ok(());
}
let binary = ensure_forte_rs_to_ts().await?;
println!("[codegen] Running forte-rs-to-ts...");
let status = Command::new(&binary)
.arg(project_dir)
.stdout(Stdio::null())
.status()
.context("Failed to run forte-rs-to-ts")?;
if !status.success() {
anyhow::bail!("forte-rs-to-ts failed with status: {}", status);
}
generate_frontend_routes(project_dir)?;
Ok(())
}
fn generate_frontend_routes(project_dir: &Path) -> Result<()> {
let pages_dir = project_dir.join("rs/src/pages");
let fe_src_dir = project_dir.join("fe/src");
if !pages_dir.exists() {
return Ok(());
}
println!("[codegen] Generating frontend routes...");
let mut routes = Vec::new();
scan_pages_dir(&pages_dir, &pages_dir, &mut routes)?;
routes.sort_by(|a, b| {
let a_dynamic = a.0.contains(':');
let b_dynamic = b.0.contains(':');
if a_dynamic != b_dynamic {
return a_dynamic.cmp(&b_dynamic);
}
a.0.cmp(&b.0)
});
let mut output = String::new();
output.push_str("// Auto-generated by forte build\n\n");
output.push_str("export const routes: Array<{ path: string; component: () => Promise<{ default: (props: any) => any }>; schema: () => Promise<{ PropsSchema: any }> }> = [\n");
for (path, fe_page_path) in &routes {
let fe_props_path = fe_page_path
.strip_suffix("/page")
.map(|s| format!("{}/.props", s))
.unwrap_or_else(|| fe_page_path.clone());
output.push_str(&format!(
" {{ path: \"{}\", component: () => import(\"{}\"), schema: () => import(\"{}\") }},\n",
path, fe_page_path, fe_props_path
));
}
output.push_str("];\n");
fs::write(fe_src_dir.join("routes.generated.ts"), output)?;
println!("[codegen] Generated {} route(s)", routes.len());
Ok(())
}
fn scan_pages_dir(
base_dir: &Path,
current_dir: &Path,
routes: &mut Vec<(String, String)>,
) -> Result<()> {
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
scan_pages_dir(base_dir, &path, routes)?;
} else if path.extension().is_some_and(|ext| ext == "rs")
&& has_handler_function(&path)?
&& let Some((route_path, fe_page_path)) = path_to_route(base_dir, &path)
{
routes.push((route_path, fe_page_path));
}
}
Ok(())
}
fn has_handler_function(path: &Path) -> Result<bool> {
let content = fs::read_to_string(path)?;
if content.contains("type Props = Redirect") {
return Ok(false);
}
Ok(content.contains("pub async fn handler"))
}
fn path_to_route(base_dir: &Path, file_path: &Path) -> Option<(String, String)> {
let relative = file_path.strip_prefix(base_dir).ok()?;
let relative_str = relative.to_string_lossy();
let mut route_path = relative_str
.trim_end_matches(".rs")
.trim_end_matches("/mod")
.replace('\\', "/");
if route_path == "index" || route_path.is_empty() {
route_path = "/".to_string();
} else {
route_path = route_path
.replace("/index", "")
.replace("[", ":")
.replace("]", "");
if !route_path.starts_with('/') {
route_path = format!("/{}", route_path);
}
}
if route_path.starts_with("/api/") {
return None;
}
let fe_page_path = build_fe_page_path(&route_path);
Some((route_path, fe_page_path))
}
fn build_fe_page_path(route_path: &str) -> String {
if route_path == "/" {
"./pages/index/page".to_string()
} else {
let path = route_path
.replace(":", "[")
.split('/')
.filter(|s| !s.is_empty())
.map(|s| {
if s.starts_with('[') {
format!("{}]", s)
} else {
s.to_string()
}
})
.collect::<Vec<_>>()
.join("/");
format!("./pages/{}/page", path)
}
}
fn build_backend(project_dir: &Path) -> Result<()> {
println!("[build] Building backend (release)...");
let status = Command::new("cargo")
.arg("build")
.arg("--release")
.arg("--target")
.arg("wasm32-wasip2")
.current_dir(project_dir.join("rs"))
.status()
.context("Failed to run cargo build")?;
if !status.success() {
anyhow::bail!("cargo build failed with status: {}", status);
}
Ok(())
}
fn build_backend_remote(project_dir: &Path, ssh_target: &str) -> Result<()> {
let dir_name = project_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "forte-project".to_string());
let remote_path = format!("/tmp/forte-remote-build-{}", dir_name);
println!("[build] Building backend on remote ({})...", ssh_target);
println!("[build] Syncing source to remote...");
let status = Command::new("rsync")
.args([
"-az",
"--delete",
"--exclude",
"target/",
"--include",
"Cargo.toml",
"--include",
"Cargo.lock",
"--include",
"rs/***",
"--exclude",
"*",
])
.arg(format!("{}/", project_dir.display()))
.arg(format!("{}:{}/", ssh_target, remote_path))
.status()
.context("Failed to run rsync")?;
if !status.success() {
anyhow::bail!("rsync to remote failed with status: {}", status);
}
println!("[build] Running cargo build on remote...");
let status = Command::new("ssh")
.arg(ssh_target)
.arg(format!(
"cd {}/rs && cargo build --release --target wasm32-wasip2",
remote_path
))
.status()
.context("Failed to run ssh")?;
if !status.success() {
anyhow::bail!("Remote cargo build failed with status: {}", status);
}
let local_release_dir = project_dir.join("rs/target/wasm32-wasip2/release");
fs::create_dir_all(&local_release_dir)?;
println!("[build] Fetching build artifact from remote...");
let status = Command::new("rsync")
.args(["-az", "--include", "*.wasm", "--exclude", "*"])
.arg(format!(
"{}:{}/rs/target/wasm32-wasip2/release/",
ssh_target, remote_path
))
.arg(format!("{}/", local_release_dir.display()))
.status()
.context("Failed to run rsync")?;
if !status.success() {
anyhow::bail!("rsync from remote failed with status: {}", status);
}
println!("[build] Remote build complete.");
Ok(())
}
fn find_wasm_binary(release_dir: &Path) -> Result<PathBuf> {
for entry in fs::read_dir(release_dir)? {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "wasm") {
return Ok(path);
}
}
anyhow::bail!("No .wasm file found in {}", release_dir.display())
}
fn build_frontend(project_dir: &Path) -> Result<()> {
let fe_dir = project_dir.join("fe");
println!("[build] Building frontend...");
let status = Command::new("npm")
.arg("run")
.arg("build")
.current_dir(&fe_dir)
.status()
.context("Failed to run npm run build")?;
if !status.success() {
anyhow::bail!("npm run build failed with status: {}", status);
}
Ok(())
}
fn create_dist(project_dir: &Path, dist_dir: &Path) -> Result<()> {
println!("[dist] Creating distribution package...");
if dist_dir.exists() {
fs::remove_dir_all(dist_dir)?;
}
fs::create_dir_all(dist_dir)?;
let backend_wasm = find_wasm_binary(&project_dir.join("rs/target/wasm32-wasip2/release"))?;
let frontend_js = project_dir.join("fe/dist/ssr/server.js");
let client_js = project_dir.join("fe/dist/client.js");
let public_dir = project_dir.join("fe/public");
fs::copy(&backend_wasm, dist_dir.join("backend.wasm"))?;
println!("[dist] Copied backend.wasm");
let dist_public = dist_dir.join("public");
fs::create_dir_all(&dist_public)?;
if public_dir.exists() {
copy_dir_recursive(&public_dir, &dist_public)?;
println!("[dist] Copied public/");
}
let client_content = fs::read(&client_js)?;
let hash = compute_hash(&client_content);
let hashed_filename = format!("client.{}.js", hash);
fs::write(dist_public.join(&hashed_filename), &client_content)?;
println!("[dist] Copied {} (hashed)", hashed_filename);
let server_content = fs::read_to_string(&frontend_js)?;
let updated_content =
server_content.replace("/public/client.js", &format!("/public/{}", hashed_filename));
fs::write(dist_dir.join("server.js"), updated_content)?;
println!("[dist] Copied server.js (updated client path)");
let manifest = format!(r#"{{"client.js":"{}"}}"#, hashed_filename);
fs::write(dist_public.join("manifest.json"), manifest)?;
println!("[dist] Generated manifest.json");
Ok(())
}
fn compute_hash(content: &[u8]) -> String {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
let hash = hasher.finish();
format!("{:08x}", hash as u32)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}