use std::fs;
use std::io;
use std::path::Path;
use std::process::{Command, Stdio};
include!("src/build_publish.rs");
const FRONTEND_INPUTS: &[&str] = &[
"index.html",
"package.json",
"package-lock.json",
"svelte.config.js",
"tsconfig.json",
"vite.config.ts",
"public",
"src",
];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
for input in FRONTEND_INPUTS {
println!("cargo:rerun-if-changed=frontend/{input}");
}
let beside_the_library = beside_the_library();
if beside_the_library {
println!("cargo:rerun-if-changed=../migrations");
println!("cargo:rerun-if-changed=../src");
println!("cargo:rerun-if-changed=../Cargo.toml");
}
let frontend_dir = Path::new("frontend");
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
let workbench_dir = Path::new(&out_dir).join("workbench");
let workbench_dir = workbench_dir.as_path();
let dist_index = workbench_dir.join("dist").join("index.html");
let mut backend_inputs = vec!["build.rs", "Cargo.toml", "src"];
if beside_the_library {
backend_inputs.extend(["../Cargo.toml", "../Cargo.lock", "../src"]);
}
let backend_build_id = hash_paths(&backend_inputs)
.unwrap_or_else(|error| panic!("failed to hash backend build inputs: {}", error));
let frontend_inputs: Vec<String> =
FRONTEND_INPUTS.iter().map(|input| format!("frontend/{input}")).collect();
let frontend_build_id = hash_paths(&frontend_inputs.iter().map(String::as_str).collect::<Vec<_>>())
.unwrap_or_else(|error| panic!("failed to hash frontend build inputs: {}", error));
println!("cargo:rustc-env=WSB_BACKEND_BUILD_ID={}", backend_build_id);
println!(
"cargo:rustc-env=WSB_FRONTEND_BUILD_ID={}",
frontend_build_id
);
let out_dir_file = std::path::Path::new(&out_dir).join("frontend_build_id.txt");
fs::write(&out_dir_file, &frontend_build_id)
.unwrap_or_else(|e| panic!("failed to write {}: {}", out_dir_file.display(), e));
let target_dir = std::path::Path::new(&out_dir)
.ancestors()
.nth(3)
.map(std::path::Path::to_path_buf)
.unwrap_or_default();
if target_dir.is_dir() {
let stable_file = target_dir.join("frontend_build_id.txt");
fs::write(&stable_file, &frontend_build_id)
.unwrap_or_else(|e| panic!("failed to write {}: {}", stable_file.display(), e));
}
let debug_build = std::env::var("PROFILE").unwrap_or_default() == "debug";
if !frontend_dir.is_dir() {
panic!("frontend/ directory not found");
}
if !frontend_dir.join("package.json").is_file() {
panic!("frontend/package.json not found");
}
let install_subcommand = if frontend_dir.join("package-lock.json").is_file() {
"ci"
} else {
panic!("frontend/package-lock.json not found; locked frontend dependencies are required");
};
let build_sources = workbench_dir.join("frontend");
copy_sources(frontend_dir, &build_sources, FRONTEND_INPUTS)
.unwrap_or_else(|error| panic!("copying the frontend to build it: {error}"));
let build_sources = build_sources.as_path();
let package_lock = build_sources.join("package-lock.json");
let node_modules_dir = build_sources.join("node_modules");
let install_stamp = node_modules_dir.join(".wsb-package-lock");
let package_lock_contents = fs::read(&package_lock)
.unwrap_or_else(|error| panic!("failed to read {}: {}", package_lock.display(), error));
let install_stamp_contents = fs::read(&install_stamp).ok();
let dependencies_ready = node_modules_dir.is_dir()
&& install_stamp_contents
.as_ref()
.is_some_and(|bytes| bytes.as_slice() == package_lock_contents.as_slice());
if !dependencies_ready {
run_npm(
build_sources,
&[install_subcommand, "--engine-strict"],
"frontend dependency install",
&frontend_build_id,
debug_build,
);
fs::write(&install_stamp, &package_lock_contents).unwrap_or_else(|error| {
panic!(
"failed to write frontend dependency stamp {}: {}",
install_stamp.display(),
error
)
});
}
await_tool(build_sources, &node_modules_dir, "vite");
let dist_dir = workbench_dir.join("dist");
let staging_dir = workbench_dir.join("dist.building");
let previous_dir = workbench_dir.join("dist.previous");
fs::create_dir_all(workbench_dir).unwrap_or_else(|error| {
panic!(
"creating the workbench output directory {}: {error}",
workbench_dir.display()
)
});
clear_scratch(&staging_dir, &previous_dir)
.unwrap_or_else(|error| panic!("preparing the frontend build: {error}"));
let staging_absolute = staging_dir.to_str().expect("OUT_DIR is UTF-8");
run_npm(
build_sources,
&["run", "build", "--", "--outDir", staging_absolute, "--emptyOutDir"],
"frontend production build",
&frontend_build_id,
debug_build,
);
let staged_index = staging_dir.join("index.html");
if !staged_index.is_file() {
panic!(
"frontend build completed without producing {}",
staged_index.display()
);
}
publish_by_rename(&staging_dir, &dist_dir, &previous_dir)
.unwrap_or_else(|error| panic!("publishing the frontend build: {error}"));
if !dist_index.is_file() {
panic!("frontend build published nothing at {}", dist_index.display());
}
}
fn run_npm(frontend_dir: &Path, args: &[&str], phase: &str, build_id: &str, debug: bool) {
let mut command = Command::new(npm_command());
command
.args(args)
.current_dir(frontend_dir)
.env("WSB_FRONTEND_BUILD_ID", build_id)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
if debug {
command.env("WSB_DEBUG_BUILD", "1");
}
let status = command.status().unwrap_or_else(|error| {
panic!(
"failed to start npm for {} in {}: {}",
phase,
frontend_dir.display(),
error
)
});
if !status.success() {
panic!(
"npm {} failed during {} in {}",
args.join(" "),
phase,
frontend_dir.display()
);
}
}
fn await_tool(frontend_dir: &Path, node_modules_dir: &Path, name: &str) {
let shim = node_modules_dir
.join(".bin")
.join(if cfg!(windows) { format!("{name}.cmd") } else { name.to_string() });
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let mut last: Option<String>;
loop {
if shim.is_file() {
match Command::new(&shim)
.arg("--version")
.current_dir(frontend_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
{
Ok(done) if done.status.success() => return,
Ok(done) => {
let said = String::from_utf8_lossy(&done.stderr);
let said = said.trim();
last = Some(if said.is_empty() {
format!("it exited with {} and said nothing", done.status)
} else {
format!("it exited with {}: {said}", done.status)
});
}
Err(error) => last = Some(format!("it could not be started: {error}")),
}
} else {
last = Some(format!("{} is not there yet", shim.display()));
}
if std::time::Instant::now() >= deadline {
let path = std::env::var("PATH").unwrap_or_else(|_| "<unset>".into());
let node = path
.split(if cfg!(windows) { ';' } else { ':' })
.find(|entry| {
let entry = entry.to_ascii_lowercase();
entry.contains("nodejs") || entry.contains("node")
})
.unwrap_or("<no node directory on PATH>");
panic!(
"npm installed the frontend's dependencies and `{name}` still cannot \
be run after 60s: {}.\nEvery script in package.json is invoked \
through {}, so the build below would fail with `'{name}' is not \
recognized`.\nThe shim resolves `node` through PATH, and this \
build script's PATH carries: {}\nPATH = {}",
last.as_deref().unwrap_or("no reason was recorded"),
shim.display(),
node,
path
);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
fn npm_command() -> &'static str {
if cfg!(windows) {
"npm.cmd"
} else {
"npm"
}
}
fn beside_the_library() -> bool {
let Ok(text) = fs::read_to_string("../Cargo.toml") else {
return false;
};
let parent: toml::Table = text
.parse()
.unwrap_or_else(|error| panic!("../Cargo.toml is not a manifest: {error}"));
parent
.get("workspace")
.and_then(|workspace| workspace.get("members"))
.and_then(|members| members.as_array())
.is_some_and(|members| members.iter().any(|member| member.as_str() == Some("daemon")))
}
fn hash_paths(paths: &[&str]) -> io::Result<String> {
let mut hasher = Fnv1a64::new();
for path in paths {
hash_path(Path::new(path), &mut hasher)?;
}
Ok(hasher.finish_hex())
}
fn hash_path(path: &Path, hasher: &mut Fnv1a64) -> io::Result<()> {
let metadata = fs::metadata(path)?;
if metadata.is_dir() {
let mut entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by(|left, right| left.path().cmp(&right.path()));
hasher.write(path.to_string_lossy().as_bytes());
hasher.write(b"\0dir\0");
for entry in entries {
hash_path(&entry.path(), hasher)?;
}
return Ok(());
}
hasher.write(path.to_string_lossy().as_bytes());
hasher.write(b"\0file\0");
hasher.write(&fs::read(path)?);
hasher.write(b"\0");
Ok(())
}
struct Fnv1a64(u64);
impl Fnv1a64 {
const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x00000100000001B3;
fn new() -> Self {
Self(Self::OFFSET_BASIS)
}
fn write(&mut self, bytes: &[u8]) {
for byte in bytes {
self.0 ^= u64::from(*byte);
self.0 = self.0.wrapping_mul(Self::PRIME);
}
}
fn finish_hex(&self) -> String {
format!("{:016x}", self.0)
}
}