use afterburner_cloud::afterburner_afb::Afb;
use afterburner_cloud::gem_client::GemClient;
use afterburner_cloud::pkg::LocalPackage;
use afterburner_wasi::bundle;
use afterburner_wasi::bundle::ensure_wasi_vfs_bundle;
use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::bundle_wasm_into_afb;
pub const GUEST_SRC_MOUNT: &str = "/src";
const GUEST_USR_MOUNT: &str = "/usr";
const GUEST_GEM_MOUNT: &str = "/gems";
pub fn compile_ruby_to_wasm(
local: LocalPackage,
_pkg_dir: &Path,
out_path: &Path,
wasm_only: bool,
) -> Result<()> {
let coord = super::super::registry::coord_str(&local);
let entry_rel = local.manifest.package.entry.clone();
let vfs_bin = resolve_wasi_vfs_bin()?;
let ruby_runtime = resolve_ruby_runtime_for_compile()?;
let gem_section: BTreeMap<String, String> = local.manifest.gem.clone();
let gem_files: BTreeMap<String, Vec<u8>> = if gem_section.is_empty() {
BTreeMap::new()
} else {
crate::cli::style::spin("resolving gems", || resolve_gems(&gem_section))
.context("resolving [gem] dependencies")?
};
let (source_bytes, _) = crate::cli::style::spin("packing source", || local.build())
.context("building source .afb")?;
let afb = Afb::from_bytes(&source_bytes).context("reparsing source .afb (this is a bug)")?;
let wasm_bytes = crate::cli::style::spin("compiling Ruby to wasm", || {
pack_ruby_wasm(&vfs_bin, &ruby_runtime, &afb, &entry_rel, &gem_files)
})?;
bundle_wasm_into_afb(&afb, wasm_bytes, out_path, &coord, wasm_only)
}
struct RubyRuntimePaths {
wasm_path: PathBuf,
usr_dir: PathBuf,
}
fn resolve_ruby_runtime_for_compile() -> Result<RubyRuntimePaths> {
if let Ok(dir) = std::env::var("BURN_RUBY_RUNTIME") {
let wasm_path = Path::new(&dir).join("ruby.wasm");
anyhow::ensure!(
wasm_path.exists(),
"BURN_RUBY_RUNTIME={dir}: ruby.wasm not found at {}",
wasm_path.display()
);
let usr_dir = std::env::var("BURN_RUBY_USR")
.map(PathBuf::from)
.unwrap_or_else(|_| Path::new(&dir).join("usr"));
anyhow::ensure!(
usr_dir.exists(),
"ruby stdlib (usr) dir not found at {}; set BURN_RUBY_USR",
usr_dir.display()
);
return Ok(RubyRuntimePaths { wasm_path, usr_dir });
}
let dir = bundle::ensure_ruby_bundle()
.map_err(|e| anyhow::anyhow!("fetching ruby.wasm runtime: {e}"))?;
let wasm_path = dir.join("ruby.wasm");
let usr_dir = dir.join("usr");
anyhow::ensure!(
wasm_path.exists(),
"ruby.wasm missing from bundle at {}",
dir.display()
);
anyhow::ensure!(
usr_dir.exists(),
"usr stdlib missing from bundle at {}",
dir.display()
);
Ok(RubyRuntimePaths { wasm_path, usr_dir })
}
fn resolve_wasi_vfs_bin() -> Result<PathBuf> {
if let Ok(path) = std::env::var("WASI_VFS") {
let p = PathBuf::from(&path);
anyhow::ensure!(
p.exists(),
"WASI_VFS={path}: binary not found at {}",
p.display()
);
return Ok(p);
}
if let Ok(found) = which_on_path("wasi-vfs") {
return Ok(found);
}
let dir = ensure_wasi_vfs_bundle().map_err(|e| anyhow::anyhow!("fetching wasi-vfs: {e}"))?;
let bin = dir.join(if cfg!(unix) {
"wasi-vfs"
} else {
"wasi-vfs.exe"
});
anyhow::ensure!(
bin.exists(),
"wasi-vfs binary missing from bundle at {}",
dir.display()
);
Ok(bin)
}
fn which_on_path(name: &str) -> Result<PathBuf> {
let path_var = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.exists() {
return Ok(candidate);
}
#[cfg(windows)]
{
let with_exe = dir.join(format!("{name}.exe"));
if with_exe.exists() {
return Ok(with_exe);
}
}
}
anyhow::bail!("{name} not found on PATH")
}
fn unique_tmp_dir(prefix: &str) -> PathBuf {
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}-{}-{n}", std::process::id()))
}
fn pack_ruby_wasm(
vfs_bin: &Path,
rt: &RubyRuntimePaths,
afb: &Afb,
_entry_rel: &str,
gem_files: &BTreeMap<String, Vec<u8>>,
) -> Result<Vec<u8>> {
let work = unique_tmp_dir("burn-ruby-vfs");
std::fs::create_dir_all(&work).context("creating ruby-vfs work dir")?;
let src_dir = work.join("src");
let gem_dir = work.join("gems");
let out_wasm = work.join("out.wasm");
for (rel, data) in &afb.source {
let dest = src_dir.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir {}", parent.display()))?;
}
std::fs::write(&dest, data).with_context(|| format!("staging {rel}"))?;
}
let has_gems = !gem_files.is_empty();
if has_gems {
for (key, data) in gem_files {
let rel = key.strip_prefix("vendor/gem/").unwrap_or(key);
let dest = gem_dir.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir gem {}", parent.display()))?;
}
std::fs::write(&dest, data).with_context(|| format!("staging gem file {key}"))?;
}
}
let mut cmd = std::process::Command::new(vfs_bin);
cmd.args(["pack", rt.wasm_path.to_str().unwrap_or("")]);
if src_dir.exists() {
let src_arg = format!("{}::{GUEST_SRC_MOUNT}", src_dir.display());
cmd.args(["--dir", &src_arg]);
}
let usr_arg = format!("{}::{GUEST_USR_MOUNT}", rt.usr_dir.display());
cmd.args(["--dir", &usr_arg]);
if has_gems && gem_dir.exists() {
let gem_arg = format!("{}::{GUEST_GEM_MOUNT}", gem_dir.display());
cmd.args(["--dir", &gem_arg]);
}
cmd.args(["-o", out_wasm.to_str().unwrap_or("")]);
let status = cmd.status().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"`wasi-vfs` was not found. Install it from \
https://github.com/kateinoigakukun/wasi-vfs/releases/tag/v0.6.3 \
or set WASI_VFS=<path>."
)
} else {
anyhow::anyhow!("spawning `wasi-vfs`: {e}")
}
})?;
let wasm_result = if status.success() {
std::fs::read(&out_wasm)
.with_context(|| format!("reading packed wasm {}", out_wasm.display()))
} else {
let code = status.code().unwrap_or(-1);
Err(anyhow::anyhow!("`wasi-vfs pack` exited with code {code}"))
};
let _ = std::fs::remove_dir_all(&work);
wasm_result
}
fn resolve_gems(gem_section: &BTreeMap<String, String>) -> Result<BTreeMap<String, Vec<u8>>> {
if gem_section.is_empty() {
return Ok(BTreeMap::new());
}
let client = GemClient::public();
let resolution = client
.resolve_all(gem_section)
.map_err(|e| anyhow::anyhow!("gem resolution: {e}"))?;
let mut out: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for pkg in &resolution.packages {
for (rel, data) in &pkg.files {
let key = format!("{}-{}/{}", pkg.name, pkg.version, rel);
out.insert(key, data.clone());
}
}
Ok(out)
}
pub fn guest_entry_path(entry_rel: &str) -> String {
format!("{GUEST_SRC_MOUNT}/{}", entry_rel.replace('\\', "/"))
}
pub fn gem_load_path_dirs(gem_files: &BTreeMap<String, Vec<u8>>) -> Vec<String> {
let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for key in gem_files.keys() {
let rel = key.strip_prefix("vendor/gem/").unwrap_or(key);
if let Some(slash) = rel.find('/') {
let gem_dir = &rel[..slash];
let rest = &rel[slash + 1..];
if rest.starts_with("lib/") || rest == "lib" {
dirs.insert(format!("{GUEST_GEM_MOUNT}/{gem_dir}/lib"));
}
}
}
dirs.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guest_entry_path_simple() {
assert_eq!(guest_entry_path("source/main.rb"), "/src/source/main.rb");
}
#[test]
fn guest_entry_path_nested() {
assert_eq!(
guest_entry_path("source/sub/app.rb"),
"/src/source/sub/app.rb"
);
}
#[test]
fn gem_load_path_dirs_empty() {
let files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
assert!(gem_load_path_dirs(&files).is_empty());
}
#[test]
fn gem_load_path_dirs_detects_lib() {
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
files.insert("color-1.0.0/lib/color.rb".to_owned(), b"".to_vec());
files.insert("color-1.0.0/README.md".to_owned(), b"".to_vec());
files.insert("widget-2.1.3/lib/widget.rb".to_owned(), b"".to_vec());
let dirs = gem_load_path_dirs(&files);
assert_eq!(dirs.len(), 2);
assert!(dirs.iter().any(|d| d.ends_with("color-1.0.0/lib")));
assert!(dirs.iter().any(|d| d.ends_with("widget-2.1.3/lib")));
}
#[test]
fn gem_load_path_dirs_with_vendor_prefix() {
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
files.insert("vendor/gem/foo-1.0.0/lib/foo.rb".to_owned(), b"".to_vec());
let dirs = gem_load_path_dirs(&files);
assert_eq!(dirs.len(), 1);
assert!(dirs[0].ends_with("foo-1.0.0/lib"), "got: {:?}", dirs);
}
}