use std::{ env::{self, VarError}, fs::{self, File}, io::{self, Read, Write}, path::{Path, PathBuf, StripPrefixError}, process::Command, }; use serde::Deserialize; use thiserror::Error; const DIST_SRC: &str = "./web/dist"; const WEB_SRC: &str = "./web"; const PACKAGE_JSON: &str = "./web/package.json"; #[derive(Deserialize, Debug)] struct Package { scripts: Scripts, } #[derive(Deserialize, Debug)] struct Scripts { #[serde(rename = "wol.build")] build: String, } #[derive(Error, Debug)] enum BuildError { #[error("IO Error: {}", .0)] Io(#[from] io::Error), #[error("Parse Error: {}", .0)] Parse(#[from] serde_json::Error), #[error("Command failed with error {}\nSTDOUT: {}\nSTDERR: {}", .0.map(|x| x.to_string()).unwrap_or(String::from("N/A")), .1, .2)] Command(Option, String, String), } fn build() -> Result<(), BuildError> { let mut file = File::open(PACKAGE_JSON)?; let mut package = String::new(); file.read_to_string(&mut package)?; let package = serde_json::from_str::(&package)?; let sh = package.scripts.build; let cmd = Command::new("/bin/sh") .arg("-c") .arg(sh) .current_dir(WEB_SRC) .output()?; if !cmd.status.success() { return Err(BuildError::Command( cmd.status.code(), String::from_utf8(cmd.stdout).unwrap_or(String::from("Could not parse STDOUT")), String::from_utf8(cmd.stderr).unwrap_or(String::from("Could not parse STDERR")), )); } Ok(()) } #[derive(Error, Debug)] #[error("{}", .0)] struct CopyError(#[from] io::Error); fn copy_dir(src: impl AsRef, dst: impl AsRef) -> Result, CopyError> { fs::create_dir_all(&dst)?; let mut paths = Vec::new(); for entry in fs::read_dir(&src)? { let entry = entry?; let r#type = entry.file_type()?; if r#type.is_dir() { let mut res = copy_dir(entry.path(), dst.as_ref().join(entry.file_name()))?; paths.append(&mut res); } else { fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; paths.push(entry.path()); } } Ok(paths) } #[derive(Error, Debug)] enum CodegenError { #[error("Could not strip path: {}", .0)] StripPath(#[from] StripPrefixError), #[error("Error parsing non UTF8 string")] Utf8, } fn codegen(paths: Vec) -> Result { // in development mode, replace codegen with a proxy #[cfg(debug_assertions)] if let Ok(proxy) = std::env::var("PROXY") { return Ok(format!( r#" mod dist {{ async fn proxy( req: axum::http::Request, ) -> Result, reqwest::StatusCode> {{ let url = req.uri().path_and_query().map(|x| x.as_str()).unwrap_or(""); println!("PROXY => {{}}", url); let url = reqwest::Url::parse(&format!("http://{}{{}}", url)) .map_err(|_| reqwest::StatusCode::INTERNAL_SERVER_ERROR)?; let req = reqwest::Request::new(req.method().to_owned(), url); let res = reqwest::Client::new() .execute(req) .await .map_err(|_| reqwest::StatusCode::INTERNAL_SERVER_ERROR)?; let up_headers = res.headers(); let mut out = axum::http::Response::builder().status(res.status()); let headers = out.headers_mut().unwrap(); for (k, v) in up_headers {{ headers.insert(k, v.to_owned()); }} let out = out .body( res.bytes() .await .map_err(|_| reqwest::StatusCode::INTERNAL_SERVER_ERROR)? .into(), ) .map_err(|_| reqwest::StatusCode::INTERNAL_SERVER_ERROR)?; Ok(out) }} pub fn main() -> axum::Router {{ axum::Router::new().fallback(axum::routing::get(proxy)) }} }} "#, proxy, )); } // idx is a unique id for the current route // this is easier than converting the path to a rust variable and avoiding collisons let mut idx = 0; // source path, route, id let paths = paths .into_iter() .map(|x| { idx += 1; let route = Path::new("/").join(x.strip_prefix(DIST_SRC)?); let route = if route.ends_with("index.html") { route.parent().unwrap_or(&route) } else { &route }; let route = route.to_str().ok_or(CodegenError::Utf8)?; let route = String::from(route); let path = x.strip_prefix(WEB_SRC).map(|x| x.to_owned())?; Ok((path, route, idx)) }) .collect::, CodegenError>>()?; let routes = &paths .iter() .map(|(path, _, idx)| { let ext = path .extension() .and_then(|x| x.to_str()) .and_then(|x| match x { // text "html" | "htm" => Some("text/html"), "css" => Some("text/css"), "js" => Some("text/javascript"), "json" => Some("application/json"), "webmanifest" => Some("application/manifest+json"), "txt" => Some("text/plain"), // image "png" => Some("image/png"), "jpeg" | "jpg" => Some("image/jpeg"), "svg" => Some("image/svg+xml"), "webp" => Some("image/webp"), "gif" => Some("image/gif"), "ico" => Some("image/vnc.microsoft.icon"), _ => None, }) .unwrap_or("application/octet-stream"); Ok(format!( r#"async fn route_{idx}() -> impl axum::response::IntoResponse {{ let mut headers = axum::http::HeaderMap::new(); headers.insert(axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("{}")); (headers, include_bytes!("{}")) }}"#, ext, path.to_str().ok_or(CodegenError::Utf8)? )) }) .collect::, CodegenError>>()? .join("\n"); let main = format!( r#" pub fn main() -> axum::Router {{ axum::Router::new(){}.fallback(axum::routing::get(async || axum::response::Redirect::to("/"))) }} "#, paths .into_iter() .map(|(_, route, idx)| format!(r#".route("{route}", axum::routing::get(route_{idx}))"#)) .collect::>() .join("") ); Ok(format!("mod dist {{ {routes} {main} }}")) } #[derive(Error, Debug)] enum Error { #[error("OUT_DIR env variable was not defined. Is this build.rs? {}", .0)] OutDir(#[from] VarError), #[error("Failed to compile ./web: {}", .0)] Web(#[from] BuildError), #[error("Failed to copy ./web/dist {}", .0)] Copy(#[from] CopyError), #[error("Codegen error: {}", .0)] Codegen(#[from] CodegenError), #[error("IO error: {}", .0)] Io(#[from] io::Error), } fn main() -> Result<(), ()> { fn main() -> Result<(), Error> { let out_dir = env::var("OUT_DIR")?; let dist_dst = Path::new(&out_dir).join("dist"); // dont build the web project if the env var PROXY is set // if PROXY is set it builds a reverse proxy instead // also dont build for release builds // in release builds we expect ./web/dist to be prebuilt #[cfg(debug_assertions)] if std::env::var("PROXY").is_err() { build()?; } let paths = copy_dir(DIST_SRC, dist_dst)?; let rust = codegen(paths)?; let mut rust_file = fs::File::create(Path::new(&out_dir).join("mod.rs"))?; rust_file.write_all(rust.as_bytes())?; println!("cargo::rerun-if-changed=web/\ncargo::rerun-if-env-changed=PROXY"); Ok(()) } let res = main(); if let Err(err) = res { eprintln!("{}", err); Err(()) } else { Ok(()) } }