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);
#[derive(Debug, Clone)]
pub enum ServeBinSource {
Local(PathBuf),
Pinned(BundleHash),
}
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,
requires_contract: crate::BUNDLE_CONTRACT_VERSION,
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)?;
stage_app_config(project_root, &mut files);
stage_data_inputs(project_root, out_dir, &mut files)?;
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 serve_bins: Vec<(String, ServeBinSource)> = serve_bins
.iter()
.map(|(triple, path)| (triple.clone(), ServeBinSource::Local(path.clone())))
.collect();
assemble_self_bundle_with(dest, name, project_root, out_dir, &serve_bins, &[], None)
}
pub fn assemble_self_bundle_with(
dest: &Path,
name: &str,
project_root: &Path,
out_dir: &Path,
serve_bins: &[(String, ServeBinSource)],
sidecar_bins: &[(String, String, PathBuf)],
built_against: Option<String>,
) -> Result<BundleManifest, BundleError> {
let mut files = collect_dir("app/dist", out_dir)?;
stage_app_config(project_root, &mut files);
stage_data_inputs(project_root, out_dir, &mut files)?;
let mut pinned: Vec<(String, BundleHash)> = Vec::new();
for (triple, source) in serve_bins {
let bundle_path = format!("bins/{triple}/serve");
match source {
ServeBinSource::Local(bin) => files.push((bundle_path, bin.clone())),
ServeBinSource::Pinned(hash) => pinned.push((bundle_path, hash.clone())),
}
}
for (bin_name, triple, bin) in sidecar_bins {
if bin_name == "serve" {
return Err(BundleError::Io(format!(
"sidecar bin for {triple} is named \"serve\", which is the runtime's own \
binary — pick a distinct name"
)));
}
files.push((format!("bins/{triple}/{bin_name}"), bin.clone()));
}
files.sort();
let mut manifest = assemble_bundle(
dest,
name,
BundleRuntime::SelfContained { built_against },
&files,
)?;
if !pinned.is_empty() {
for (path, hash) in pinned {
manifest.content.insert(path, hash);
}
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_bundle_from_files(
dest: &Path,
name: &str,
runtime_version: &str,
mut files: Vec<BundleFile>,
serve_bins: &[(String, ServeBinSource)],
sidecar_bins: &[(String, String, PathBuf)],
built_against: Option<String>,
) -> Result<BundleManifest, BundleError> {
if serve_bins.is_empty() {
files.sort();
return assemble_bundle(
dest,
name,
BundleRuntime::Mesofact {
version: runtime_version.to_string(),
},
&files,
);
}
let mut pinned: Vec<(String, BundleHash)> = Vec::new();
for (triple, source) in serve_bins {
let bundle_path = format!("bins/{triple}/serve");
match source {
ServeBinSource::Local(bin) => files.push((bundle_path, bin.clone())),
ServeBinSource::Pinned(hash) => pinned.push((bundle_path, hash.clone())),
}
}
for (bin_name, triple, bin) in sidecar_bins {
if bin_name == "serve" {
return Err(BundleError::Io(format!(
"sidecar bin for {triple} is named \"serve\", which is the runtime's own \
binary — pick a distinct name"
)));
}
files.push((format!("bins/{triple}/{bin_name}"), bin.clone()));
}
files.sort();
let mut manifest = assemble_bundle(
dest,
name,
BundleRuntime::SelfContained { built_against },
&files,
)?;
if !pinned.is_empty() {
for (path, hash) in pinned {
manifest.content.insert(path, hash);
}
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 collect_component_files(
project_root: &Path,
out_dir: &Path,
mount: Option<&str>,
include_config: bool,
) -> Result<Vec<BundleFile>, BundleError> {
let dist_prefix = match mount {
Some(m) if !m.is_empty() => format!("app/dist/html/{m}"),
_ => "app/dist".to_string(),
};
let mut files = collect_dir(&dist_prefix, out_dir)?;
if include_config {
stage_app_config(project_root, &mut files);
stage_data_inputs(project_root, out_dir, &mut files)?;
}
Ok(files)
}
fn stage_app_config(project_root: &Path, files: &mut Vec<BundleFile>) {
for name in ["mesofact.routes.ts", "mesofact.config.toml"] {
let src = project_root.join(name);
if src.exists() {
files.push((format!("app/{name}"), src));
}
}
}
fn stage_data_inputs(
project_root: &Path,
out_dir: &Path,
files: &mut Vec<BundleFile>,
) -> Result<(), BundleError> {
#[derive(serde::Deserialize)]
struct ManifestSliver {
#[serde(default)]
routes: Vec<RouteSliver>,
}
#[derive(serde::Deserialize)]
struct RouteSliver {
#[serde(default)]
data_inputs: Vec<String>,
}
let manifest_path = out_dir.join("manifest.json");
let Ok(raw) = fs::read_to_string(&manifest_path) else {
return Ok(());
};
let manifest: ManifestSliver = serde_json::from_str(&raw)
.map_err(|e| BundleError::Io(format!("parsing {}: {e}", manifest_path.display())))?;
let mut seen = std::collections::BTreeSet::new();
for rel in manifest.routes.into_iter().flat_map(|r| r.data_inputs) {
if Path::new(&rel)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
|| Path::new(&rel).is_absolute()
{
return Err(BundleError::Io(format!(
"data_inputs entry {rel:?} escapes the app root; declare it as a path under the project"
)));
}
if !seen.insert(rel.clone()) {
continue; }
let src = project_root.join(&rel);
if !src.is_file() {
return Err(BundleError::Io(format!(
"manifest declares data_inputs {rel:?} but {} is not a file — a --revalidate receiver would fail to re-render this route on the node",
src.display()
)));
}
files.push((format!("app/{}", rel.replace('\\', "/")), src));
}
Ok(())
}
#[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 a_mounted_component_stages_inside_the_served_root() {
const SERVED_ROOT: &str = "app/dist/html/";
let dir = TempDir::new().unwrap();
write(&dir.path().join("index.html"), b"<app>");
write(&dir.path().join("app-abc123_bg.wasm"), b"\0asm");
let files =
collect_component_files(dir.path(), dir.path(), Some("app"), false).unwrap();
let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec![
"app/dist/html/app/app-abc123_bg.wasm",
"app/dist/html/app/index.html",
]
);
for (path, _) in &files {
assert!(
path.starts_with(SERVED_ROOT),
"{path} is staged outside the served root {SERVED_ROOT} — unreachable by any URL"
);
}
}
#[test]
fn an_unmounted_component_stages_its_own_html_tree_at_the_dist_root() {
let dir = TempDir::new().unwrap();
write(&dir.path().join("html/index.html"), b"<html>");
write(&dir.path().join("html/market.html"), b"<html>");
let files = collect_component_files(dir.path(), dir.path(), None, false).unwrap();
let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec!["app/dist/html/index.html", "app/dist/html/market.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_stages_mesofact_config_when_present() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
write(&out_dir.join("index.html"), b"<html>");
write(
&project.path().join("mesofact.config.toml"),
b"[publish]\nbucket = \"yah-dev\"\nprefix = \"yah-marketing/cloud\"\n",
);
let manifest =
assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
.unwrap();
assert!(manifest.content.contains_key("app/mesofact.config.toml"));
assert_eq!(
fs::read_to_string(dest.path().join("app/mesofact.config.toml")).unwrap(),
"[publish]\nbucket = \"yah-dev\"\nprefix = \"yah-marketing/cloud\"\n"
);
}
#[test]
fn assemble_omits_config_when_absent() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
write(&out_dir.join("index.html"), b"<html>");
let manifest =
assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
.unwrap();
assert!(!manifest.content.contains_key("app/mesofact.config.toml"));
}
#[test]
fn assemble_stages_declared_data_inputs_once_each() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
write(&out_dir.join("index.html"), b"<html>");
write(
&out_dir.join("manifest.json"),
br#"{"routes":[
{"route":"/releases","data_inputs":["src/data/releases.json"]},
{"route":"/issues","data_inputs":["src/data/issues.json"]},
{"route":"/issues/:id","data_inputs":["src/data/issues.json"]},
{"route":"/"}
]}"#,
);
write(
&project.path().join("src/data/releases.json"),
br#"{"releases":[]}"#,
);
write(&project.path().join("src/data/issues.json"), br#"{"items":[]}"#);
let manifest =
assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
.unwrap();
assert!(manifest.content.contains_key("app/src/data/releases.json"));
assert!(manifest.content.contains_key("app/src/data/issues.json"));
assert_eq!(
fs::read_to_string(dest.path().join("app/src/data/releases.json")).unwrap(),
r#"{"releases":[]}"#
);
assert_eq!(
manifest
.content
.keys()
.filter(|k| k.as_str() == "app/src/data/issues.json")
.count(),
1
);
}
#[test]
fn assemble_rejects_declared_data_input_that_is_not_on_disk() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
write(&out_dir.join("index.html"), b"<html>");
write(
&out_dir.join("manifest.json"),
br#"{"routes":[{"route":"/releases","data_inputs":["src/data/releases.json"]}]}"#,
);
let err = assemble_vanilla_bundle(
dest.path(),
"yah-marketing",
"0.8.20",
project.path(),
&out_dir,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("src/data/releases.json"), "got {msg}");
assert!(msg.contains("revalidate"), "got {msg}");
}
#[test]
fn assemble_rejects_data_input_escaping_the_app_root() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
write(&out_dir.join("index.html"), b"<html>");
write(
&out_dir.join("manifest.json"),
br#"{"routes":[{"route":"/x","data_inputs":["../secrets.json"]}]}"#,
);
let err = assemble_vanilla_bundle(
dest.path(),
"yah-marketing",
"0.8.20",
project.path(),
&out_dir,
)
.unwrap_err();
assert!(err.to_string().contains("escapes the app root"), "got {err}");
}
#[test]
fn assemble_without_a_build_manifest_stages_no_data_inputs() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
write(&out_dir.join("index.html"), b"<html>");
let manifest =
assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
.unwrap();
assert!(!manifest.content.keys().any(|k| k.starts_with("app/src/")));
}
#[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 { built_against: None }));
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]
fn assemble_self_bundle_with_pins_a_serve_bin_by_digest() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
write(&out_dir.join("index.html"), b"<html>");
let pinned_hash = BundleHash::of(b"a previously published serve binary");
let manifest = assemble_self_bundle_with(
dest.path(),
"yah-marketing",
project.path(),
&out_dir,
&[(
"x86_64-unknown-linux-musl".to_string(),
ServeBinSource::Pinned(pinned_hash.clone()),
)],
&[],
Some("fp-abc123".to_string()),
)
.unwrap();
assert_eq!(
manifest.content.get("bins/x86_64-unknown-linux-musl/serve"),
Some(&pinned_hash)
);
assert!(!dest.path().join("bins/x86_64-unknown-linux-musl/serve").exists());
assert_eq!(
manifest.runtime,
BundleRuntime::SelfContained {
built_against: Some("fp-abc123".to_string())
}
);
let text = fs::read_to_string(dest.path().join("manifest.toml")).unwrap();
assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
}
#[test]
fn assemble_self_bundle_with_mixes_pinned_and_local_bins() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
let local_bin = project.path().join("serve-aarch64");
write(&out_dir.join("index.html"), b"<html>");
write(&local_bin, b"\x7fELF-aarch64");
let pinned_hash = BundleHash::of(b"already published x86_64 serve");
let manifest = assemble_self_bundle_with(
dest.path(),
"yah-marketing",
project.path(),
&out_dir,
&[
(
"x86_64-unknown-linux-musl".to_string(),
ServeBinSource::Pinned(pinned_hash.clone()),
),
(
"aarch64-unknown-linux-musl".to_string(),
ServeBinSource::Local(local_bin),
),
],
&[],
None,
)
.unwrap();
assert_eq!(
manifest.content.get("bins/x86_64-unknown-linux-musl/serve"),
Some(&pinned_hash)
);
assert!(dest.path().join("bins/aarch64-unknown-linux-musl/serve").exists());
assert!(!dest.path().join("bins/x86_64-unknown-linux-musl/serve").exists());
}
#[test]
fn assemble_self_bundle_with_carries_sidecar_bins() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
let serve = project.path().join("serve");
let feed = project.path().join("almanac-feed");
write(&out_dir.join("index.html"), b"<html>");
write(&serve, b"\x7fELF-serve");
write(&feed, b"\x7fELF-feed");
let manifest = assemble_self_bundle_with(
dest.path(),
"yah-marketing",
project.path(),
&out_dir,
&[(
"x86_64-unknown-linux-musl".to_string(),
ServeBinSource::Local(serve),
)],
&[(
"almanac-feed".to_string(),
"x86_64-unknown-linux-musl".to_string(),
feed,
)],
None,
)
.unwrap();
assert!(manifest
.content
.contains_key("bins/x86_64-unknown-linux-musl/serve"));
assert!(manifest
.content
.contains_key("bins/x86_64-unknown-linux-musl/almanac-feed"));
}
#[test]
fn assemble_rejects_a_sidecar_named_serve() {
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
let bin = project.path().join("bin");
write(&out_dir.join("index.html"), b"<html>");
write(&bin, b"\x7fELF-ish");
let err = assemble_self_bundle_with(
dest.path(),
"yah-marketing",
project.path(),
&out_dir,
&[],
&[(
"serve".to_string(),
"x86_64-unknown-linux-musl".to_string(),
bin,
)],
None,
)
.unwrap_err();
assert!(err.to_string().contains("runtime's own"), "got {err}");
}
#[test]
#[cfg(unix)]
fn sidecar_bins_are_executable() {
use std::os::unix::fs::PermissionsExt;
let project = TempDir::new().unwrap();
let out_dir = project.path().join("dist");
let dest = TempDir::new().unwrap();
let feed = project.path().join("almanac-feed");
write(&out_dir.join("index.html"), b"<html>");
write(&feed, b"\x7fELF-feed");
assemble_self_bundle_with(
dest.path(),
"yah-marketing",
project.path(),
&out_dir,
&[],
&[(
"almanac-feed".to_string(),
"x86_64-unknown-linux-musl".to_string(),
feed,
)],
None,
)
.unwrap();
let mode = fs::metadata(dest.path().join("bins/x86_64-unknown-linux-musl/almanac-feed"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "sidecar must stay executable");
}
#[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::self_contained(), &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");
}
}