Skip to main content

yah_mesofact_bundle/
assemble.rs

1//! Assemble a built mesofact app into a W272 **bundle** — the content-addressed
2//! unit the store publishes and a node materializes.
3//!
4//! Part of R599-F2, moved here from `mesofact-build` under R599-T5. It lived in
5//! the mesofact-build subcamp originally, but that crate pulls rolldown +
6//! lightningcss + deno_core (V8), so anything linking it inherits the whole
7//! build toolchain. The operator driver (`yah cloud bundle`) needs to *emit* a
8//! bundle without being able to *build* one, and the yah CLI is deliberately
9//! V8-less (R490-F2). Assembly is pure `std::fs` + blake3 + toml, so it belongs
10//! next to the manifest types it writes; `mesofact-build` re-exports from here
11//! so there is exactly one implementation and no drift.
12//!
13//! A vanilla bundle's tree (W272 §1):
14//!
15//! ```text
16//! <bundle>/
17//!   manifest.toml
18//!   app/
19//!     mesofact.routes.ts        # routes/config
20//!     dist/…                    # built TS + rendered assets (the build out_dir)
21//! ```
22//!
23//! A custom (`runtime = "self"`) bundle additionally carries
24//! `bins/<triple>/serve`. The only shape difference between the two is whether
25//! `bins/` is present and what `runtime` names — there is one format.
26
27use std::collections::BTreeMap;
28use std::fs;
29use std::path::{Path, PathBuf};
30
31use crate::{BundleError, BundleHash, BundleManifest, BundleRuntime, SCHEMA_VERSION};
32
33/// Wrap an I/O failure with the path that caused it. Bare `io::Error` loses the
34/// filename, which is the only thing that makes an assembly failure actionable.
35fn io(context: impl AsRef<str>, e: std::io::Error) -> BundleError {
36    BundleError::Io(format!("{}: {e}", context.as_ref()))
37}
38
39/// One file destined for a bundle: `(path within the bundle, source on disk)`.
40/// The bundle path is always relative and forward-slashed, e.g.
41/// `"app/dist/index.html"` or `"bins/x86_64-unknown-linux-musl/serve"`.
42pub type BundleFile = (String, PathBuf);
43
44/// Recursively enumerate the regular files under `dir`, mapping each to a
45/// bundle path `"<prefix>/<relative-path>"`. Results are sorted for determinism.
46pub fn collect_dir(prefix: &str, dir: &Path) -> Result<Vec<BundleFile>, BundleError> {
47    let mut out = Vec::new();
48    let mut stack = vec![dir.to_path_buf()];
49    while let Some(d) = stack.pop() {
50        let entries = fs::read_dir(&d).map_err(|e| io(format!("reading {}", d.display()), e))?;
51        for entry in entries {
52            let entry = entry.map_err(|e| io(format!("reading {}", d.display()), e))?;
53            let ft = entry
54                .file_type()
55                .map_err(|e| io(format!("stat {}", entry.path().display()), e))?;
56            let p = entry.path();
57            if ft.is_dir() {
58                stack.push(p);
59            } else if ft.is_file() {
60                let rel = p.strip_prefix(dir).expect("walker yields paths under dir");
61                let rel_str = rel.to_string_lossy().replace('\\', "/");
62                out.push((format!("{prefix}/{rel_str}"), p));
63            }
64        }
65    }
66    out.sort();
67    Ok(out)
68}
69
70/// Assemble a bundle tree at `dest` from `files`, returning the [`BundleManifest`].
71///
72/// Copies each source file to `<dest>/<bundle-path>`, hashes it (BLAKE3) into the
73/// manifest `content` map, and writes `<dest>/manifest.toml` last. Files under
74/// `bins/` are made executable (0755) — they are serve binaries. Re-assembling
75/// over an existing `dest` overwrites in place (idempotent). `dest` MUST NOT sit
76/// inside any of the source dirs being collected, or the copy would recurse into
77/// its own output.
78pub fn assemble_bundle(
79    dest: &Path,
80    name: &str,
81    runtime: BundleRuntime,
82    files: &[BundleFile],
83) -> Result<BundleManifest, BundleError> {
84    fs::create_dir_all(dest).map_err(|e| io(format!("creating bundle dir {}", dest.display()), e))?;
85
86    let mut content = BTreeMap::new();
87    for (bundle_path, src) in files {
88        let bytes = fs::read(src).map_err(|e| io(format!("reading {}", src.display()), e))?;
89        let hash = BundleHash::of(&bytes);
90        let out = dest.join(bundle_path);
91        if let Some(parent) = out.parent() {
92            fs::create_dir_all(parent)
93                .map_err(|e| io(format!("creating {}", parent.display()), e))?;
94        }
95        fs::write(&out, &bytes).map_err(|e| io(format!("writing {}", out.display()), e))?;
96
97        // Serve binaries must stay executable after the copy.
98        #[cfg(unix)]
99        if bundle_path.starts_with("bins/") {
100            use std::os::unix::fs::PermissionsExt;
101            let mut perm = fs::metadata(&out)
102                .map_err(|e| io(format!("stat {}", out.display()), e))?
103                .permissions();
104            perm.set_mode(0o755);
105            fs::set_permissions(&out, perm)
106                .map_err(|e| io(format!("chmod +x {}", out.display()), e))?;
107        }
108
109        content.insert(bundle_path.clone(), hash);
110    }
111
112    let manifest = BundleManifest {
113        schema_version: SCHEMA_VERSION,
114        name: name.to_string(),
115        runtime,
116        content,
117    };
118    let toml = manifest.to_toml_string()?;
119    fs::write(dest.join("manifest.toml"), toml).map_err(|e| {
120        io(
121            format!("writing {}", dest.join("manifest.toml").display()),
122            e,
123        )
124    })?;
125    Ok(manifest)
126}
127
128/// Assemble a **vanilla** bundle from a finished mesofact build: `app/` holds the
129/// routes file plus the built `dist/` tree, no `bins/`, and `runtime =
130/// "mesofact/<runtime_version>"` so the node resolves the stock serve runtime.
131///
132/// `out_dir` is the build's dist directory; `runtime_version` is the version of
133/// `mesofact serve` that should serve this bundle (e.g. the framework version
134/// the app was built against).
135///
136/// NOTE: nothing currently stages `runtimes/<runtime>/<triple>/serve` on a node,
137/// so a vanilla bundle will fail serve-binary resolution at deploy time. Until
138/// that staging path exists, prefer [`assemble_self_bundle`].
139pub fn assemble_vanilla_bundle(
140    dest: &Path,
141    name: &str,
142    runtime_version: &str,
143    project_root: &Path,
144    out_dir: &Path,
145) -> Result<BundleManifest, BundleError> {
146    let mut files = collect_dir("app/dist", out_dir)?;
147    let routes = project_root.join("mesofact.routes.ts");
148    if routes.exists() {
149        files.push(("app/mesofact.routes.ts".to_string(), routes));
150    }
151    files.sort();
152    assemble_bundle(
153        dest,
154        name,
155        BundleRuntime::Mesofact {
156            version: runtime_version.to_string(),
157        },
158        &files,
159    )
160}
161
162/// Assemble a **self-contained** (`runtime = "self"`) bundle: the vanilla tree
163/// plus `bins/<triple>/serve`, so the node serves it with the binary the bundle
164/// carries and resolves no runtime asset at all.
165///
166/// This is the shape to reach for today. A vanilla bundle depends on a node-side
167/// runtime cache that nothing populates yet, whereas a self-contained bundle is
168/// closed over its own serve binary and works against a stock node.
169pub fn assemble_self_bundle(
170    dest: &Path,
171    name: &str,
172    project_root: &Path,
173    out_dir: &Path,
174    serve_bins: &[(String, PathBuf)],
175) -> Result<BundleManifest, BundleError> {
176    let mut files = collect_dir("app/dist", out_dir)?;
177    let routes = project_root.join("mesofact.routes.ts");
178    if routes.exists() {
179        files.push(("app/mesofact.routes.ts".to_string(), routes));
180    }
181    for (triple, bin) in serve_bins {
182        files.push((format!("bins/{triple}/serve"), bin.clone()));
183    }
184    files.sort();
185    assemble_bundle(dest, name, BundleRuntime::SelfContained, &files)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use tempfile::TempDir;
192
193    fn write(path: &Path, bytes: &[u8]) {
194        fs::create_dir_all(path.parent().unwrap()).unwrap();
195        fs::write(path, bytes).unwrap();
196    }
197
198    #[test]
199    fn collect_dir_walks_recursively_and_prefixes() {
200        let dir = TempDir::new().unwrap();
201        write(&dir.path().join("index.html"), b"a");
202        write(&dir.path().join("assets/main.js"), b"b");
203        write(&dir.path().join("assets/nested/x.css"), b"c");
204
205        let files = collect_dir("app/dist", dir.path()).unwrap();
206        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
207        assert_eq!(
208            paths,
209            vec![
210                "app/dist/assets/main.js",
211                "app/dist/assets/nested/x.css",
212                "app/dist/index.html",
213            ]
214        );
215    }
216
217    #[test]
218    fn assemble_bundle_copies_files_hashes_and_writes_manifest() {
219        let src = TempDir::new().unwrap();
220        let dest = TempDir::new().unwrap();
221        write(&src.path().join("index.html"), b"<html>home</html>");
222
223        let files = vec![("app/index.html".to_string(), src.path().join("index.html"))];
224        let manifest = assemble_bundle(
225            dest.path(),
226            "yah-marketing",
227            BundleRuntime::Mesofact {
228                version: "0.8.20".into(),
229            },
230            &files,
231        )
232        .unwrap();
233
234        // Manifest content matches the copied bytes' hash.
235        assert_eq!(
236            manifest.content.get("app/index.html").unwrap(),
237            &BundleHash::of(b"<html>home</html>")
238        );
239        // The file was copied into the bundle tree.
240        assert_eq!(
241            fs::read(dest.path().join("app/index.html")).unwrap(),
242            b"<html>home</html>"
243        );
244        // manifest.toml on disk round-trips back to the returned manifest.
245        let text = fs::read_to_string(dest.path().join("manifest.toml")).unwrap();
246        assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
247    }
248
249    #[test]
250    fn assemble_vanilla_bundle_stages_routes_and_dist() {
251        let project = TempDir::new().unwrap();
252        let out_dir = project.path().join("dist");
253        let dest = TempDir::new().unwrap();
254        write(
255            &project.path().join("mesofact.routes.ts"),
256            b"export default {}",
257        );
258        write(&out_dir.join("index.html"), b"<html>");
259        write(&out_dir.join("hydrate/app.hash.js"), b"hydrate");
260
261        let manifest =
262            assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
263                .unwrap();
264
265        assert!(matches!(manifest.runtime, BundleRuntime::Mesofact { .. }));
266        assert!(manifest.content.contains_key("app/mesofact.routes.ts"));
267        assert!(manifest.content.contains_key("app/dist/index.html"));
268        assert!(manifest.content.contains_key("app/dist/hydrate/app.hash.js"));
269        // A vanilla bundle carries no serve binaries.
270        assert!(!manifest.content.keys().any(|k| k.starts_with("bins/")));
271        assert!(dest.path().join("app/mesofact.routes.ts").exists());
272    }
273
274    #[test]
275    fn assemble_self_bundle_carries_its_serve_binary() {
276        let project = TempDir::new().unwrap();
277        let out_dir = project.path().join("dist");
278        let dest = TempDir::new().unwrap();
279        let bin = project.path().join("serve");
280        write(&out_dir.join("index.html"), b"<html>");
281        write(&bin, b"\x7fELF-ish");
282
283        let manifest = assemble_self_bundle(
284            dest.path(),
285            "yah-marketing",
286            project.path(),
287            &out_dir,
288            &[("x86_64-unknown-linux-musl".to_string(), bin)],
289        )
290        .unwrap();
291
292        assert!(matches!(manifest.runtime, BundleRuntime::SelfContained));
293        assert!(manifest
294            .content
295            .contains_key("bins/x86_64-unknown-linux-musl/serve"));
296        assert!(manifest.content.contains_key("app/dist/index.html"));
297        // The wire form is what kamaji matches on to skip runtime resolution.
298        assert_eq!(manifest.runtime.as_wire(), "self");
299    }
300
301    #[test]
302    #[cfg(unix)]
303    fn bins_are_made_executable() {
304        use std::os::unix::fs::PermissionsExt;
305        let src = TempDir::new().unwrap();
306        let dest = TempDir::new().unwrap();
307        write(&src.path().join("serve"), b"\x7fELF-ish");
308
309        let files = vec![(
310            "bins/x86_64-unknown-linux-musl/serve".to_string(),
311            src.path().join("serve"),
312        )];
313        assemble_bundle(dest.path(), "app", BundleRuntime::SelfContained, &files).unwrap();
314
315        let mode = fs::metadata(dest.path().join("bins/x86_64-unknown-linux-musl/serve"))
316            .unwrap()
317            .permissions()
318            .mode();
319        assert_eq!(mode & 0o111, 0o111, "serve binary should be executable");
320    }
321}