use crate::cli::fe_runtime;
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;
#[derive(Debug)]
pub struct BuildOptions {
pub project_dir: PathBuf,
pub static_base_url: 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?;
build_backend(&project_dir)?;
build_frontend(&project_dir, options.static_base_url.as_deref())?;
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.7";
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() {
fe_runtime::ensure(project_dir)?;
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);
}
fe_runtime::ensure(project_dir)?;
generate_frontend_routes(project_dir)?;
Ok(())
}
fn generate_frontend_routes(project_dir: &Path) -> Result<()> {
let pages_dir = project_dir.join("rs/src/pages");
if !pages_dir.exists() {
return Ok(());
}
println!("[codegen] Generating frontend routes...");
let prefix = fe_runtime::page_import_prefix(project_dir);
let mut routes = Vec::new();
scan_pages_dir(&pages_dir, &pages_dir, prefix, &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");
let output_path = fe_runtime::routes_generated(project_dir);
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(output_path, output)?;
println!("[codegen] Generated {} route(s)", routes.len());
Ok(())
}
fn scan_pages_dir(
base_dir: &Path,
current_dir: &Path,
page_import_prefix: &str,
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, page_import_prefix, 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, page_import_prefix)
{
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,
page_import_prefix: &str,
) -> 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, page_import_prefix);
Some((route_path, fe_page_path))
}
fn build_fe_page_path(route_path: &str, prefix: &str) -> String {
if route_path == "/" {
format!("{}/pages/index/page", prefix)
} 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", prefix, 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 find_wasm_binary(release_dir: &Path, project_dir: &Path) -> Result<PathBuf> {
let cargo_toml = project_dir.join("rs/Cargo.toml");
let content = fs::read_to_string(&cargo_toml)
.with_context(|| format!("read {}", cargo_toml.display()))?;
#[derive(serde::Deserialize)]
struct CargoToml {
package: CargoPackage,
}
#[derive(serde::Deserialize)]
struct CargoPackage {
name: String,
}
let parsed: CargoToml =
toml::from_str(&content).with_context(|| format!("parse {}", cargo_toml.display()))?;
let wasm_name = format!("{}.wasm", parsed.package.name.replace('-', "_"));
let wasm_path = release_dir.join(&wasm_name);
if !wasm_path.exists() {
anyhow::bail!(
"expected wasm '{}' not found in {}",
wasm_name,
release_dir.display()
);
}
Ok(wasm_path)
}
fn build_frontend(project_dir: &Path, static_base_url: Option<&str>) -> Result<()> {
let fe_dir = project_dir.join("fe");
println!("[build] Building frontend...");
if let Some(config_path) = fe_runtime::vite_config(project_dir) {
let server_entry = fe_runtime::ssr_entry(project_dir);
let mut client_cmd = Command::new("npx");
client_cmd
.args(["vite", "build", "--config"])
.arg(&config_path)
.current_dir(&fe_dir);
if let Some(base) = static_base_url {
client_cmd.env("VITE_PUBLIC_URL", base);
}
let status = client_cmd
.status()
.context("Failed to run vite build (client)")?;
if !status.success() {
anyhow::bail!("vite client build failed with status: {}", status);
}
let mut ssr_cmd = Command::new("npx");
ssr_cmd
.args(["vite", "build", "--ssr"])
.arg(&server_entry)
.args(["--config"])
.arg(&config_path)
.current_dir(&fe_dir);
if let Some(base) = static_base_url {
ssr_cmd.env("VITE_PUBLIC_URL", base);
}
let status = ssr_cmd.status().context("Failed to run vite build --ssr")?;
if !status.success() {
anyhow::bail!("vite ssr build failed with status: {}", status);
}
} else {
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"),
project_dir,
)?;
let frontend_js = project_dir.join("fe/dist/ssr/server.js");
fs::copy(&backend_wasm, dist_dir.join("backend.wasm"))?;
println!("[dist] Copied backend.wasm");
let server_content = fs::read(&frontend_js)?;
fs::write(dist_dir.join("server.js"), server_content)?;
println!("[dist] Copied server.js");
Ok(())
}