use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use std::process::Command;
pub fn run(name: &str) -> Result<()> {
let project_dir = Path::new(name);
if project_dir.exists() {
anyhow::bail!("Directory '{}' already exists", name);
}
fs::create_dir_all(project_dir.join("rs/src/pages/index"))?;
fs::create_dir_all(project_dir.join("rs/.cargo"))?;
fs::create_dir_all(project_dir.join("fe/src/pages/index"))?;
fs::create_dir_all(project_dir.join("fe/public"))?;
fs::write(project_dir.join("Forte.toml"), generate_forte_toml(name))?;
fs::write(project_dir.join(".gitignore"), generate_root_gitignore())?;
fs::write(
project_dir.join("Cargo.toml"),
generate_workspace_cargo_toml(),
)?;
fs::write(project_dir.join("rs/.gitignore"), generate_rs_gitignore())?;
fs::write(project_dir.join("rs/Cargo.toml"), generate_cargo_toml())?;
fs::write(
project_dir.join("rs/.cargo/config.toml"),
generate_cargo_config(),
)?;
fs::write(project_dir.join("rs/src/lib.rs"), generate_lib_rs())?;
fs::write(
project_dir.join("rs/src/pages/index/mod.rs"),
generate_index_mod_rs(),
)?;
fs::write(project_dir.join("rs/build.rs"), generate_build_rs())?;
fs::write(project_dir.join("fe/.gitignore"), generate_fe_gitignore())?;
fs::write(
project_dir.join("fe/package.json"),
generate_package_json(name),
)?;
fs::write(project_dir.join("fe/tsconfig.json"), generate_tsconfig())?;
fs::write(
project_dir.join("fe/vite.config.ts"),
generate_vite_config(),
)?;
fs::write(project_dir.join("fe/src/server.tsx"), generate_server_tsx())?;
fs::write(project_dir.join("fe/src/client.tsx"), generate_client_tsx())?;
fs::write(
project_dir.join("fe/src/pages/index/page.tsx"),
generate_index_page_tsx(),
)?;
fs::write(
project_dir.join("fe/public/robots.txt"),
generate_robots_txt(),
)?;
install_npm_packages(project_dir)?;
println!("Created project '{}'", name);
println!();
println!("Next steps:");
println!(" cd {}", name);
println!(" forte dev");
Ok(())
}
fn install_npm_packages(project_dir: &Path) -> Result<()> {
let fe_dir = project_dir.join("fe");
println!("Installing npm packages...");
let deps = ["react", "react-dom"];
let status = Command::new("npm")
.arg("install")
.args(deps)
.current_dir(&fe_dir)
.status()
.context("Failed to run npm install")?;
if !status.success() {
anyhow::bail!("npm install failed");
}
let dev_deps = [
"@types/react",
"@types/react-dom",
"@vitejs/plugin-react",
"typescript",
"vite",
];
let status = Command::new("npm")
.arg("install")
.arg("-D")
.args(dev_deps)
.current_dir(&fe_dir)
.status()
.context("Failed to run npm install -D")?;
if !status.success() {
anyhow::bail!("npm install -D failed");
}
Ok(())
}
fn generate_forte_toml(name: &str) -> String {
format!(
r#"[project]
name = "{name}"
"#
)
}
fn generate_root_gitignore() -> &'static str {
"/target\n"
}
fn generate_workspace_cargo_toml() -> &'static str {
r#"[workspace]
resolver = "3"
members = ["rs"]
"#
}
fn generate_rs_gitignore() -> &'static str {
"/target\n"
}
fn generate_fe_gitignore() -> &'static str {
"/node_modules\n"
}
fn generate_cargo_toml() -> String {
let forte_json_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("Failed to get parent of CARGO_MANIFEST_DIR")
.join("forte-json");
format!(
r#"[package]
name = "backend"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
anyhow = "1"
cookie = "0.18"
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
http = "1"
wstd = "0.6"
forte-json = {{ path = "{}" }}
"#,
forte_json_path.display()
)
}
fn generate_cargo_config() -> &'static str {
r#"[build]
target = "wasm32-wasip2"
[target.wasm32-wasip2]
runner = "wasmtime -Shttp"
"#
}
fn generate_lib_rs() -> &'static str {
r#"mod route_generated;
"#
}
fn generate_index_mod_rs() -> &'static str {
r#"use anyhow::Result;
use cookie::CookieJar;
use http::HeaderMap;
use serde::Serialize;
#[derive(Serialize)]
pub enum Props {
Ok { message: String },
}
pub async fn handler(_headers: HeaderMap, _jar: CookieJar) -> Result<Props> {
Ok(Props::Ok {
message: "Hello from Forte!".to_string(),
})
}
"#
}
fn generate_build_rs() -> &'static str {
r##"use std::env;
use std::fs;
use std::path::Path;
fn main() {
generate_routes();
}
fn write_if_changed(path: &Path, content: &str) {
if let Ok(existing) = fs::read_to_string(path) {
if existing == content {
return;
}
}
fs::write(path, content).unwrap();
}
fn generate_routes() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let pages_dir = Path::new(&manifest_dir).join("src/pages");
let output_path = Path::new(&manifest_dir).join("src/route_generated.rs");
println!("cargo:rerun-if-changed=src/pages");
let mut output = String::new();
output.push_str("// Auto-generated by build.rs\n\n");
if pages_dir.join("index/mod.rs").exists() {
output.push_str("#[path = \"pages/index/mod.rs\"]\n");
output.push_str("mod pages_index;\n\n");
}
output.push_str("use anyhow::Result;\n");
output.push_str("use http::header::COOKIE;\n");
output.push_str("use http::HeaderMap;\n");
output.push_str("use wstd::http::{Error, Request, Response, StatusCode, body::Body};\n\n");
output.push_str("fn make_cookie_jar(headers: &HeaderMap) -> cookie::CookieJar {\n");
output.push_str(" let mut jar = cookie::CookieJar::new();\n");
output.push_str(" let Some(cookie) = headers.get(COOKIE) else {\n");
output.push_str(" return jar;\n");
output.push_str(" };\n");
output.push_str(" let Ok(cookie_str) = cookie.to_str() else {\n");
output.push_str(" return jar;\n");
output.push_str(" };\n\n");
output.push_str(" for cookie in cookie::Cookie::split_parse(cookie_str) {\n");
output.push_str(" let Ok(cookie) = cookie else { continue };\n");
output.push_str(" jar.add_original(cookie.into_owned());\n");
output.push_str(" }\n\n");
output.push_str(" jar\n");
output.push_str("}\n\n");
output.push_str("#[wstd::http_server]\n");
output.push_str("pub async fn main(request: Request<Body>) -> Result<Response<Body>, Error> {\n");
output.push_str(" let (parts, _body) = request.into_parts();\n");
output.push_str(" let headers = parts.headers;\n");
output.push_str(" let jar = make_cookie_jar(&headers);\n");
output.push_str(" let path = parts.uri.path();\n\n");
output.push_str(" if path == \"/\" {\n");
output.push_str(" match pages_index::handler(headers, jar).await {\n");
output.push_str(" Ok(props) => {\n");
output.push_str(" let stream = forte_json::to_stream(&props);\n");
output.push_str(" return Ok(Response::new(Body::from_stream(stream)));\n");
output.push_str(" }\n");
output.push_str(" Err(e) => {\n");
output.push_str(" return Ok(Response::builder()\n");
output.push_str(" .status(StatusCode::INTERNAL_SERVER_ERROR)\n");
output.push_str(" .body(Body::from(format!(\"Error: {:?}\", e)))\n");
output.push_str(" .unwrap());\n");
output.push_str(" }\n");
output.push_str(" }\n");
output.push_str(" }\n\n");
output.push_str(" Ok(Response::builder()\n");
output.push_str(" .status(StatusCode::NOT_FOUND)\n");
output.push_str(" .body(Body::empty())\n");
output.push_str(" .unwrap())\n");
output.push_str("}\n");
write_if_changed(&output_path, &output);
}
"##
}
fn generate_package_json(name: &str) -> String {
format!(
r#"{{
"name": "{name}-frontend",
"private": true,
"type": "module",
"scripts": {{
"build": "vite build && vite build --ssr src/server.tsx --outDir dist/ssr"
}}
}}
"#
)
}
fn generate_tsconfig() -> &'static str {
r#"{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}
"#
}
fn generate_vite_config() -> &'static str {
r#"import { defineConfig, Plugin } from "vite";
import react from "@vitejs/plugin-react";
function exitOnStdinClose(): Plugin {
return {
name: "exit-on-stdin-close",
configureServer() {
process.stdin.resume();
process.stdin.on("close", () => {
process.exit(0);
});
},
};
}
export default defineConfig(({ isSsrBuild }) => ({
plugins: [react(), exitOnStdinClose()],
optimizeDeps: {
include: ["react", "react-dom"],
},
build: {
rollupOptions: {
input: isSsrBuild ? "src/server.tsx" : "src/client.tsx",
output: {
entryFileNames: isSsrBuild ? "server.js" : "client.js",
},
},
},
ssr: {
external: ["react", "react-dom"],
},
}));
"#
}
fn generate_server_tsx() -> &'static str {
r#"import { renderToString } from "react-dom/server";
import { routes } from "./routes.generated";
function matchRoute(pathname: string): { route: typeof routes[0]; params: Record<string, string> } | null {
for (const route of routes) {
const routeParts = route.path.split("/");
const pathParts = pathname.split("/");
if (routeParts.length !== pathParts.length) continue;
const params: Record<string, string> = {};
let match = true;
for (let i = 0; i < routeParts.length; i++) {
if (routeParts[i].startsWith(":")) {
params[routeParts[i].slice(1)] = pathParts[i];
} else if (routeParts[i] !== pathParts[i]) {
match = false;
break;
}
}
if (match) {
return { route, params };
}
}
return null;
}
function escapeJsonForScript(json: string): string {
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
}
const isDev = import.meta.env?.DEV ?? false;
export async function render(url: string, props: any): Promise<string> {
const urlObj = new URL(url, "http://localhost");
const matched = matchRoute(urlObj.pathname);
if (!matched) {
return "Not Found";
}
const allProps = { ...props, params: matched.params };
const pageModule = await matched.route.component();
const PageComponent = pageModule.default;
const html = renderToString(<PageComponent {...allProps} />);
const propsJson = escapeJsonForScript(JSON.stringify(allProps));
const viteScripts = `<script type="module" src="/@vite/client"></script>`;
const clientScript = `/src/client.tsx`;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Forte App</title>
${viteScripts}
</head>
<body>
<div id="root">${html}</div>
<script>window.__FORTE_PROPS__ = ${propsJson};</script>
<script type="module" src="${clientScript}"></script>
</body>
</html>`;
}
(globalThis as any).handler = async function handler(request: Request): Promise<Response> {
const props = await request.json();
const url = new URL(request.url);
const matched = matchRoute(url.pathname);
if (matched) {
const allProps = { ...props, params: matched.params };
const pageModule = await matched.route.component();
const PageComponent = pageModule.default;
const html = renderToString(<PageComponent {...allProps} />);
const propsJson = escapeJsonForScript(JSON.stringify(allProps));
const viteScripts = isDev
? `<script type="module" src="/@vite/client"></script>`
: "";
const clientScript = isDev
? `/src/client.tsx`
: `/public/client.js`;
return new Response(
`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Forte App</title>
${viteScripts}
</head>
<body>
<div id="root">${html}</div>
<script>window.__FORTE_PROPS__ = ${propsJson};</script>
<script type="module" src="${clientScript}"></script>
</body>
</html>`,
{
headers: { "Content-Type": "text/html" },
}
);
}
return new Response("Not Found", { status: 404 });
};
"#
}
fn generate_index_page_tsx() -> &'static str {
r#"import type { Props } from "./.props";
export default function IndexPage(props: Props) {
if (props.t !== "Ok") {
return <div>Error loading page</div>;
}
return (
<div>
<h1>Welcome to Forte</h1>
<p>{props.v.message}</p>
</div>
);
}
"#
}
fn generate_robots_txt() -> &'static str {
r#"User-agent: *
Allow: /
"#
}
fn generate_client_tsx() -> &'static str {
r#"import { hydrateRoot } from "react-dom/client";
import { routes } from "./routes.generated";
function matchRoute(pathname: string): { route: typeof routes[0]; params: Record<string, string> } | null {
for (const route of routes) {
const routeParts = route.path.split("/");
const pathParts = pathname.split("/");
if (routeParts.length !== pathParts.length) continue;
const params: Record<string, string> = {};
let match = true;
for (let i = 0; i < routeParts.length; i++) {
if (routeParts[i].startsWith(":")) {
params[routeParts[i].slice(1)] = pathParts[i];
} else if (routeParts[i] !== pathParts[i]) {
match = false;
break;
}
}
if (match) {
return { route, params };
}
}
return null;
}
async function hydrate() {
const props = (window as any).__FORTE_PROPS__;
const matched = matchRoute(window.location.pathname);
if (matched) {
const pageModule = await matched.route.component();
const PageComponent = pageModule.default;
const allProps = { ...props, params: matched.params };
hydrateRoot(document.getElementById("root")!, <PageComponent {...allProps} />);
}
}
hydrate();
"#
}