use std::path::{Path, PathBuf};
use std::sync::Arc;
use rolldown::{Bundler, BundlerOptions, InputItem, OutputFormat, Platform, SourceMapType};
use rolldown_common::Output;
use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Module, WriteOptions, WriteOptionsEndianness, async_with};
use crate::engine::caught_to_script_error;
use crate::error::ScriptError;
pub struct CompiledBundle {
pub module_name: String,
pub bytecode: Arc<[u8]>,
source_map: Option<sourcemap::SourceMap>,
}
pub async fn bundle_source(entry_paths: &[PathBuf], cwd: &Path) -> Result<(String, Option<String>), ScriptError> {
if entry_paths.is_empty() {
return Err(ScriptError::internal("no step entry files".to_string()));
}
let input: Vec<InputItem> = entry_paths
.iter()
.map(|p| InputItem {
name: None,
import: p.to_string_lossy().into_owned(),
})
.collect();
let options = BundlerOptions {
input: Some(input),
cwd: Some(cwd.to_path_buf()),
platform: Some(Platform::Neutral),
format: Some(OutputFormat::Esm),
sourcemap: Some(SourceMapType::Hidden),
..Default::default()
};
let mut bundler = Bundler::new(options).map_err(|e| ScriptError::internal(format!("rolldown init: {e:?}")))?;
let out = Box::pin(bundler.generate())
.await
.map_err(|e| ScriptError::internal(format!("rolldown bundle: {e:?}")))?;
for asset in &out.assets {
if let Output::Chunk(chunk) = asset {
if chunk.is_entry {
let code = chunk.code.clone();
return Ok(match &chunk.map {
Some(m) => (code, Some(m.to_json_string())),
None => (code, None),
});
}
}
}
Err(ScriptError::internal("rolldown produced no entry chunk".to_string()))
}
pub async fn bundle_and_compile(entry_paths: &[PathBuf], cwd: &Path) -> Result<CompiledBundle, ScriptError> {
let module_name = "ferridriver-bdd-steps.js".to_string();
let cache_key = crate::bytecode_cache::entry_key(entry_paths);
if let Some(hit) = crate::bytecode_cache::load(cache_key) {
let source_map = hit
.source_map_json
.and_then(|j| sourcemap::SourceMap::from_slice(j.as_bytes()).ok());
return Ok(CompiledBundle {
module_name,
bytecode: Arc::from(hit.bytecode.into_boxed_slice()),
source_map,
});
}
let (code, map_json) = Box::pin(bundle_source(entry_paths, cwd)).await?;
let name = module_name.clone();
let runtime = AsyncRuntime::new().map_err(|e| ScriptError::internal(format!("bytecode runtime: {e}")))?;
let ctx = AsyncContext::full(&runtime)
.await
.map_err(|e| ScriptError::internal(format!("bytecode context: {e}")))?;
let bytecode: Vec<u8> = async_with!(ctx => |ctx| {
let module = Module::declare(ctx.clone(), name.into_bytes(), code.into_bytes())
.catch(&ctx)
.map_err(|e| caught_to_script_error(e, ""))?;
module
.write(WriteOptions {
endianness: WriteOptionsEndianness::Native,
..Default::default()
})
.map_err(|e| ScriptError::internal(format!("module write: {e}")))
})
.await?;
let inputs = crate::bytecode_cache::collect_inputs(entry_paths, map_json.as_deref(), cwd);
crate::bytecode_cache::store(cache_key, &bytecode, &module_name, map_json.as_deref(), None, &inputs);
let source_map = map_json.and_then(|j| sourcemap::SourceMap::from_slice(j.as_bytes()).ok());
Ok(CompiledBundle {
module_name,
bytecode: Arc::from(bytecode.into_boxed_slice()),
source_map,
})
}
pub async fn eval_bundle(actx: &AsyncContext, bundle: &CompiledBundle) -> Result<(), ScriptError> {
let bytecode = Arc::clone(&bundle.bytecode);
let label = bundle.module_name.clone();
async_with!(actx => |ctx| {
#[allow(unsafe_code)]
let module = match (unsafe { Module::load(ctx.clone(), &bytecode) }).catch(&ctx) {
Ok(m) => m,
Err(e) => return Err(caught_to_script_error(e, &label)),
};
let promise = match module.eval().catch(&ctx) {
Ok((_evaluated, p)) => p,
Err(e) => return Err(caught_to_script_error(e, &label)),
};
match promise.into_future::<()>().await.catch(&ctx) {
Ok(()) => Ok(()),
Err(e) => Err(caught_to_script_error(e, &label)),
}
})
.await
}
impl CompiledBundle {
#[must_use]
pub fn remap(&self, line: u32, col: u32) -> Option<(String, u32, u32)> {
let sm = self.source_map.as_ref()?;
let token = sm.lookup_token(line.saturating_sub(1), col.saturating_sub(1))?;
let src = token.get_source().unwrap_or("<unknown>").to_string();
Some((src, token.get_src_line() + 1, token.get_src_col() + 1))
}
#[must_use]
pub fn source_files(&self, cwd: &Path) -> Vec<PathBuf> {
let Some(sm) = self.source_map.as_ref() else {
return Vec::new();
};
sm.sources()
.map(|src| {
let p = Path::new(src);
if p.is_absolute() { p.to_path_buf() } else { cwd.join(p) }
})
.collect()
}
}
#[must_use]
pub fn is_typescript_path(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("ts" | "tsx" | "mts" | "cts")
)
}
#[must_use]
pub fn source_is_es_module(source: &str) -> bool {
source.lines().any(|line| {
let t = line.trim_start();
let static_import = t
.strip_prefix("import")
.is_some_and(|rest| matches!(rest.as_bytes().first(), Some(b' ' | b'\t' | b'{' | b'\'' | b'"')));
static_import
|| t.starts_with("export ")
|| t.starts_with("export\t")
|| t.starts_with("export{")
|| t.starts_with("export*")
})
}
pub struct CompiledPlugin {
pub path: PathBuf,
pub index: usize,
pub bytecode: Arc<[u8]>,
pub manifests_json: String,
}
type PluginCache = std::sync::Mutex<rustc_hash::FxHashMap<u64, (Arc<[u8]>, String)>>;
static PLUGIN_BYTECODE_CACHE: std::sync::OnceLock<PluginCache> = std::sync::OnceLock::new();
fn plugin_cache() -> &'static PluginCache {
PLUGIN_BYTECODE_CACHE.get_or_init(|| std::sync::Mutex::new(rustc_hash::FxHashMap::default()))
}
fn cache_key(path: &Path, bytes: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.hash(&mut h);
bytes.hash(&mut h);
h.finish()
}
pub async fn compile_and_extract_plugins(files: &[PathBuf]) -> (Vec<CompiledPlugin>, Vec<(PathBuf, ScriptError)>) {
enum Slot {
Hit(Arc<[u8]>, String),
Miss { inmem_key: u64, disk_key: u64 },
Failed(ScriptError),
}
let mut bytes: Vec<Vec<u8>> = Vec::with_capacity(files.len());
let mut slots: Vec<Slot> = Vec::with_capacity(files.len());
for path in files {
match std::fs::read(path) {
Ok(b) => {
let inmem_key = cache_key(path, &b);
let cached = plugin_cache().lock().ok().and_then(|c| c.get(&inmem_key).cloned());
let disk_key = crate::bytecode_cache::entry_key(std::slice::from_ref(path));
match cached {
Some((bc, mj)) => slots.push(Slot::Hit(bc, mj)),
None => match crate::bytecode_cache::load(disk_key) {
Some(entry) => {
let bc: Arc<[u8]> = Arc::from(entry.bytecode.into_boxed_slice());
let mj = entry.aux.unwrap_or_else(|| "[]".to_string());
if let Ok(mut cache) = plugin_cache().lock() {
cache.insert(inmem_key, (bc.clone(), mj.clone()));
}
slots.push(Slot::Hit(bc, mj));
},
None => slots.push(Slot::Miss { inmem_key, disk_key }),
},
}
bytes.push(b);
},
Err(e) => {
slots.push(Slot::Failed(ScriptError::internal(format!(
"read {}: {e}",
path.display()
))));
bytes.push(Vec::new());
},
}
}
let miss_idx: Vec<usize> = slots
.iter()
.enumerate()
.filter_map(|(i, s)| matches!(s, Slot::Miss { .. }).then_some(i))
.collect();
let bundles = futures::future::join_all(miss_idx.iter().map(|&i| {
let path = files[i].clone();
async move {
let cwd = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
(i, Box::pin(bundle_source(std::slice::from_ref(&path), &cwd)).await)
}
}))
.await;
let mut bundled_code: rustc_hash::FxHashMap<usize, String> = rustc_hash::FxHashMap::default();
let mut bundled_map: rustc_hash::FxHashMap<usize, Option<String>> = rustc_hash::FxHashMap::default();
for (i, res) in bundles {
match res {
Ok((code, map)) => {
bundled_code.insert(i, code);
bundled_map.insert(i, map);
},
Err(e) => slots[i] = Slot::Failed(e),
}
}
let runtime_ctx = match AsyncRuntime::new() {
Ok(r) => match AsyncContext::full(&r).await {
Ok(c) => Some((r, c)),
Err(e) => {
let err = ScriptError::internal(format!("plugin bytecode context: {e}"));
for s in &mut slots {
if matches!(s, Slot::Miss { .. }) {
*s = Slot::Failed(err.clone());
}
}
None
},
},
Err(e) => {
let err = ScriptError::internal(format!("plugin bytecode runtime: {e}"));
for s in &mut slots {
if matches!(s, Slot::Miss { .. }) {
*s = Slot::Failed(err.clone());
}
}
None
},
};
if let Some((_runtime, actx)) = runtime_ctx {
for i in &miss_idx {
let i = *i;
let Slot::Miss { inmem_key, disk_key } = slots[i] else {
continue;
};
let Some(code) = bundled_code.get(&i) else { continue };
let module_name = format!("ferri_plugin_{i}.js");
match compile_extract_one(&actx, &module_name, code).await {
Ok((bc, mj)) => {
let bc: Arc<[u8]> = Arc::from(bc.into_boxed_slice());
if let Ok(mut cache) = plugin_cache().lock() {
cache.insert(inmem_key, (bc.clone(), mj.clone()));
}
let cwd = files[i].parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
let map = bundled_map.get(&i).cloned().flatten();
let inputs = crate::bytecode_cache::collect_inputs(std::slice::from_ref(&files[i]), map.as_deref(), &cwd);
crate::bytecode_cache::store(disk_key, &bc, &module_name, map.as_deref(), Some(&mj), &inputs);
slots[i] = Slot::Hit(bc, mj);
},
Err(e) => slots[i] = Slot::Failed(e),
}
}
}
let mut survivors: Vec<CompiledPlugin> = Vec::new();
let mut failures: Vec<(PathBuf, ScriptError)> = Vec::new();
for (i, slot) in slots.into_iter().enumerate() {
match slot {
Slot::Hit(bytecode, manifests_json) => survivors.push(CompiledPlugin {
path: files[i].clone(),
index: survivors.len(),
bytecode,
manifests_json,
}),
Slot::Failed(e) => failures.push((files[i].clone(), e)),
Slot::Miss { .. } => failures.push((
files[i].clone(),
ScriptError::internal("plugin compile produced no output".to_string()),
)),
}
}
(survivors, failures)
}
async fn compile_extract_one(
actx: &AsyncContext,
module_name: &str,
code: &str,
) -> Result<(Vec<u8>, String), ScriptError> {
let name = module_name.to_string();
let code = code.to_string();
let label = module_name.to_string();
async_with!(actx => |ctx| {
crate::bindings::install_bdd(&ctx)
.map_err(|e| ScriptError::internal(format!("install extension registry: {e}")))?;
{
let fd = rquickjs::Object::new(ctx.clone())
.map_err(|e| ScriptError::internal(format!("ferridriver global: {e}")))?;
fd.set("host", "mcp")
.map_err(|e| ScriptError::internal(format!("ferridriver.host: {e}")))?;
ctx
.globals()
.set("ferridriver", fd)
.map_err(|e| ScriptError::internal(format!("install ferridriver global: {e}")))?;
}
let module = Module::declare(ctx.clone(), name.clone().into_bytes(), code.into_bytes())
.catch(&ctx)
.map_err(|e| caught_to_script_error(e, &label))?;
let bytecode = module
.write(WriteOptions {
endianness: WriteOptionsEndianness::Native,
..Default::default()
})
.map_err(|e| ScriptError::internal(format!("plugin module write: {e}")))?;
let before = crate::bindings::tools_len(&ctx)?;
#[allow(unsafe_code)]
let loaded = (unsafe { Module::load(ctx.clone(), &bytecode) })
.catch(&ctx)
.map_err(|e| caught_to_script_error(e, &label))?;
let promise = loaded
.eval()
.catch(&ctx)
.map_err(|e| caught_to_script_error(e, &label))?
.1;
promise
.into_future::<()>()
.await
.catch(&ctx)
.map_err(|e| caught_to_script_error(e, &label))?;
let all = crate::bindings::tools_snapshot(&ctx)?;
let slice = all.get(before..).unwrap_or(&[]);
let manifests_json =
serde_json::to_string(slice).map_err(|e| ScriptError::internal(format!("serialise manifests: {e}")))?;
Ok((bytecode, manifests_json))
})
.await
}