use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::{BundleError, BundleHash, BundleManifest, BundleRuntime, SCHEMA_VERSION};
fn io(context: impl AsRef<str>, e: std::io::Error) -> BundleError {
BundleError::Io(format!("{}: {e}", context.as_ref()))
}
pub type BundleFile = (String, PathBuf);
pub fn collect_dir(prefix: &str, dir: &Path) -> Result<Vec<BundleFile>, BundleError> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let entries = fs::read_dir(&d).map_err(|e| io(format!("reading {}", d.display()), e))?;
for entry in entries {
let entry = entry.map_err(|e| io(format!("reading {}", d.display()), e))?;
let ft = entry
.file_type()
.map_err(|e| io(format!("stat {}", entry.path().display()), e))?;
let p = entry.path();
if ft.is_dir() {
stack.push(p);
} else if ft.is_file() {
let rel = p.strip_prefix(dir).expect("walker yields paths under dir");
let rel_str = rel.to_string_lossy().replace('\\', "/");
out.push((format!("{prefix}/{rel_str}"), p));
}
}
}
out.sort();
Ok(out)
}
pub fn assemble_bundle(
dest: &Path,
name: &str,
runtime: BundleRuntime,
files: &[BundleFile],
) -> Result<BundleManifest, BundleError> {
fs::create_dir_all(dest).map_err(|e| io(format!("creating bundle dir {}", dest.display()), e))?;
let mut content = BTreeMap::new();
for (bundle_path, src) in files {
let bytes = fs::read(src).map_err(|e| io(format!("reading {}", src.display()), e))?;
let hash = BundleHash::of(&bytes);
let out = dest.join(bundle_path);
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)
.map_err(|e| io(format!("creating {}", parent.display()), e))?;
}
fs::write(&out, &bytes).map_err(|e| io(format!("writing {}", out.display()), e))?;
#[cfg(unix)]
if bundle_path.starts_with("bins/") {
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(&out)
.map_err(|e| io(format!("stat {}", out.display()), e))?
.permissions();
perm.set_mode(0o755);
fs::set_permissions(&out, perm)
.map_err(|e| io(format!("chmod +x {}", out.display()), e))?;
}
content.insert(bundle_path.clone(), hash);
}
let manifest = BundleManifest {
schema_version: SCHEMA_VERSION,
name: name.to_string(),
runtime,
content,
};
let toml = manifest.to_toml_string()?;
fs::write(dest.join("manifest.toml"), toml).map_err(|e| {
io(
format!("writing {}", dest.join("manifest.toml").display()),
e,
)
})?;
Ok(manifest)
}
pub fn assemble_vanilla_bundle(
dest: &Path,
name: &str,
runtime_version: &str,
project_root: &Path,
out_dir: &Path,
) -> Result<BundleManifest, BundleError> {
let mut files = collect_dir("app/dist", out_dir)?;
let routes = project_root.join("mesofact.routes.ts");
if routes.exists() {
files.push(("app/mesofact.routes.ts".to_string(), routes));
}
files.sort();
assemble_bundle(
dest,
name,
BundleRuntime::Mesofact {
version: runtime_version.to_string(),
},
&files,
)
}
pub fn assemble_self_bundle(
dest: &Path,
name: &str,
project_root: &Path,
out_dir: &Path,
serve_bins: &[(String, PathBuf)],
) -> Result<BundleManifest, BundleError> {
let mut files = collect_dir("app/dist", out_dir)?;
let routes = project_root.join("mesofact.routes.ts");
if routes.exists() {
files.push(("app/mesofact.routes.ts".to_string(), routes));
}
for (triple, bin) in serve_bins {
files.push((format!("bins/{triple}/serve"), bin.clone()));
}
files.sort();
assemble_bundle(dest, name, BundleRuntime::SelfContained, &files)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(path: &Path, bytes: &[u8]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
#[test]
fn collect_dir_walks_recursively_and_prefixes() {
let dir = TempDir::new().unwrap();
write(&dir.path().join("index.html"), b"a");
write(&dir.path().join("assets/main.js"), b"b");
write(&dir.path().join("assets/nested/x.css"), b"c");
let files = collect_dir("app/dist", dir.path()).unwrap();
let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec![
"app/dist/assets/main.js",
"app/dist/assets/nested/x.css",
"app/dist/index.html",
]
);
}
#[test]
fn assemble_bundle_copies_files_hashes_and_writes_manifest() {
let src = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
write(&src.path().join("index.html"), b"<html>home</html>");
let files = vec![("app/index.html".to_string(), src.path().join("index.html"))];
let manifest = assemble_bundle(
dest.path(),
"yah-marketing",
BundleRuntime::Mesofact {
version: "0.8.20".into(),
},
&files,
)
.unwrap();
assert_eq!(
manifest.content.get("app/index.html").unwrap(),
&BundleHash::of(b"<html>home</html>")
);
assert_eq!(
fs::read(dest.path().join("app/index.html")).unwrap(),
b"<html>home</html>"
);
let text = fs::read_to_string(dest.path().join("manifest.toml")).unwrap();
assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
}
#[test]
fn assemble_vanilla_bundle_stages_routes_and_dist() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
write(
&project.path().join("mesofact.routes.ts"),
b"export default {}",
);
write(&out_dir.join("index.html"), b"<html>");
write(&out_dir.join("hydrate/app.hash.js"), b"hydrate");
let manifest =
assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
.unwrap();
assert!(matches!(manifest.runtime, BundleRuntime::Mesofact { .. }));
assert!(manifest.content.contains_key("app/mesofact.routes.ts"));
assert!(manifest.content.contains_key("app/dist/index.html"));
assert!(manifest.content.contains_key("app/dist/hydrate/app.hash.js"));
assert!(!manifest.content.keys().any(|k| k.starts_with("bins/")));
assert!(dest.path().join("app/mesofact.routes.ts").exists());
}
#[test]
fn assemble_self_bundle_carries_its_serve_binary() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
let bin = project.path().join("serve");
write(&out_dir.join("index.html"), b"<html>");
write(&bin, b"\x7fELF-ish");
let manifest = assemble_self_bundle(
dest.path(),
"yah-marketing",
project.path(),
&out_dir,
&[("x86_64-unknown-linux-musl".to_string(), bin)],
)
.unwrap();
assert!(matches!(manifest.runtime, BundleRuntime::SelfContained));
assert!(manifest
.content
.contains_key("bins/x86_64-unknown-linux-musl/serve"));
assert!(manifest.content.contains_key("app/dist/index.html"));
assert_eq!(manifest.runtime.as_wire(), "self");
}
#[test]
#[cfg(unix)]
fn bins_are_made_executable() {
use std::os::unix::fs::PermissionsExt;
let src = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
write(&src.path().join("serve"), b"\x7fELF-ish");
let files = vec![(
"bins/x86_64-unknown-linux-musl/serve".to_string(),
src.path().join("serve"),
)];
assemble_bundle(dest.path(), "app", BundleRuntime::SelfContained, &files).unwrap();
let mode = fs::metadata(dest.path().join("bins/x86_64-unknown-linux-musl/serve"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "serve binary should be executable");
}
}