pub mod bindings;
pub mod host_bridge;
pub mod store;
use std::sync::Arc;
use fraiseql_error::Result;
use self::{host_bridge::DynHostContext, store::StoreData};
use crate::{
HostContext,
runtime::FunctionRuntime,
types::{EventPayload, FunctionModule, FunctionResult, ResourceLimits},
};
#[derive(Debug, Clone)]
pub struct WasmConfig {
pub enable_simd: bool,
pub compilation_cache_dir: Option<std::path::PathBuf>,
}
impl Default for WasmConfig {
fn default() -> Self {
Self {
enable_simd: true,
compilation_cache_dir: None,
}
}
}
pub struct WasmRuntime {
engine: wasmtime::Engine,
epoch_ticker: Arc<EpochTicker>,
}
struct EpochTicker {
shutdown: std::sync::atomic::AtomicBool,
handle: std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
}
impl EpochTicker {
const TICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
fn start(engine: wasmtime::Engine) -> Arc<Self> {
let ticker = Arc::new(Self {
shutdown: std::sync::atomic::AtomicBool::new(false),
handle: std::sync::Mutex::new(None),
});
let ticker_clone = Arc::clone(&ticker);
let handle = std::thread::Builder::new()
.name("wasm-epoch-ticker".to_string())
.spawn(move || {
while !ticker_clone.shutdown.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(Self::TICK_INTERVAL);
engine.increment_epoch();
}
})
.expect("failed to spawn epoch ticker thread");
*ticker.handle.lock().expect("lock") = Some(handle);
ticker
}
}
impl Drop for EpochTicker {
fn drop(&mut self) {
self.shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
let handle = self.handle.lock().expect("lock").take();
if let Some(handle) = handle {
let _ = handle.join();
}
}
}
impl std::fmt::Debug for WasmRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WasmRuntime").finish()
}
}
impl Clone for WasmRuntime {
fn clone(&self) -> Self {
Self {
engine: self.engine.clone(),
epoch_ticker: Arc::clone(&self.epoch_ticker),
}
}
}
impl WasmRuntime {
pub fn new(config: &WasmConfig) -> Result<Self> {
let mut wasm_config = wasmtime::Config::new();
wasm_config.epoch_interruption(true);
wasm_config.wasm_simd(config.enable_simd);
wasm_config.wasm_relaxed_simd(false);
wasm_config.wasm_bulk_memory(true);
wasm_config.wasm_component_model(true);
let engine = wasmtime::Engine::new(&wasm_config).map_err(|e| {
fraiseql_error::FraiseQLError::Validation {
message: format!("Failed to create WASM engine: {e}"),
path: None,
}
})?;
let ticker = EpochTicker::start(engine.clone());
Ok(Self {
engine,
epoch_ticker: ticker,
})
}
}
impl WasmRuntime {
pub async fn invoke_with_context(
&self,
module: &FunctionModule,
event: EventPayload,
host_context: Arc<dyn DynHostContext>,
limits: ResourceLimits,
) -> Result<FunctionResult> {
let start = std::time::Instant::now();
let timeout = limits.max_duration;
let component = wasmtime::component::Component::new(&self.engine, &module.bytecode)
.map_err(|e| fraiseql_error::FraiseQLError::Validation {
message: format!("Failed to load WASM component: {e}"),
path: None,
})?;
let mut store_data = StoreData::new(event, limits);
store_data.set_host_context(host_context);
let mut store = wasmtime::Store::new(&self.engine, store_data);
store.limiter(|data| data);
let deadline_ticks = (timeout.as_millis() / EpochTicker::TICK_INTERVAL.as_millis()).max(1);
#[allow(clippy::cast_possible_truncation)]
store.set_epoch_deadline(deadline_ticks as u64);
let mut linker = wasmtime::component::Linker::new(&self.engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker).map_err(|e| {
fraiseql_error::FraiseQLError::Validation {
message: format!("Failed to link WASI imports: {e}"),
path: None,
}
})?;
bindings::FraiseqlFunction::add_to_linker::<
StoreData,
wasmtime::component::HasSelf<StoreData>,
>(&mut linker, |data| data)
.map_err(|e| fraiseql_error::FraiseQLError::Validation {
message: format!("Failed to link host imports: {e}"),
path: None,
})?;
let instance = linker.instantiate_async(&mut store, &component).await.map_err(|e| {
fraiseql_error::FraiseQLError::Internal {
message: format!("Failed to instantiate WASM component: {e}"),
source: None,
}
})?;
let event_json = serde_json::to_string(store.data().event_payload_ref())
.unwrap_or_else(|_| "{}".to_string());
let handle_func = instance
.get_typed_func::<(&str,), (std::result::Result<String, String>,)>(&mut store, "handle")
.map_err(|e| fraiseql_error::FraiseQLError::Internal {
message: format!("Failed to get handle export: {e}"),
source: None,
})?;
let call_result = handle_func.call_async(&mut store, (&event_json,)).await;
let duration = start.elapsed();
let collected_logs = store.data().logs.clone();
let peak_memory = store.data().memory_peak_bytes;
let result_value = match call_result {
Ok((Ok(result_json),)) => Some(serde_json::from_str(&result_json).unwrap_or_else(
|e| serde_json::json!({ "error": format!("invalid JSON from guest: {e}") }),
)),
Ok((Err(error_msg),)) => Some(serde_json::json!({ "error": error_msg })),
Err(trap) => {
let msg = trap.to_string();
if msg.contains("epoch deadline") || duration > timeout {
Some(serde_json::json!({ "error": "function execution timed out" }))
} else {
Some(serde_json::json!({ "error": format!("WASM trap: {msg}") }))
}
},
};
Ok(FunctionResult {
value: result_value,
logs: collected_logs,
duration,
memory_peak_bytes: peak_memory,
})
}
}
impl FunctionRuntime for WasmRuntime {
#[allow(clippy::manual_async_fn)] fn invoke<H>(
&self,
module: &FunctionModule,
event: EventPayload,
host: &H,
limits: ResourceLimits,
) -> impl std::future::Future<Output = Result<FunctionResult>> + Send
where
H: HostContext + ?Sized,
{
let host_context: Arc<dyn DynHostContext> = Arc::from(HostContextSnapshot::capture(host));
let runtime = self.clone();
let module = module.clone();
async move { runtime.invoke_with_context(&module, event, host_context, limits).await }
}
fn supported_extensions(&self) -> &[&str] {
&[".wasm"]
}
fn supports_hot_reload(&self) -> bool {
false
}
fn name(&self) -> &'static str {
"wasm"
}
}
struct HostContextSnapshot {
event_payload: EventPayload,
auth_context: std::result::Result<serde_json::Value, String>,
}
impl HostContextSnapshot {
fn capture<H: HostContext + ?Sized>(host: &H) -> Self {
let event_payload = host.event_payload().clone();
let auth_context = host.auth_context().map_err(|e| e.to_string());
Self {
event_payload,
auth_context,
}
}
}
impl DynHostContext for HostContextSnapshot {
fn query(
&self,
_graphql: &str,
_variables: serde_json::Value,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = fraiseql_error::Result<serde_json::Value>> + Send + '_,
>,
> {
Box::pin(async {
Err(fraiseql_error::FraiseQLError::Unsupported {
message: "query not available in snapshot context".to_string(),
})
})
}
fn sql_query(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = fraiseql_error::Result<Vec<serde_json::Value>>>
+ Send
+ '_,
>,
> {
Box::pin(async {
Err(fraiseql_error::FraiseQLError::Unsupported {
message: "sql_query not available in snapshot context".to_string(),
})
})
}
fn http_request(
&self,
_method: &str,
_url: &str,
_headers: &[(String, String)],
_body: Option<&[u8]>,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = fraiseql_error::Result<crate::host::HttpResponse>>
+ Send
+ '_,
>,
> {
Box::pin(async {
Err(fraiseql_error::FraiseQLError::Unsupported {
message: "http_request not available in snapshot context".to_string(),
})
})
}
fn storage_get(
&self,
_bucket: &str,
_key: &str,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = fraiseql_error::Result<Vec<u8>>> + Send + '_>,
> {
Box::pin(async {
Err(fraiseql_error::FraiseQLError::Unsupported {
message: "storage_get not available in snapshot context".to_string(),
})
})
}
fn storage_put(
&self,
_bucket: &str,
_key: &str,
_body: &[u8],
_content_type: &str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = fraiseql_error::Result<()>> + Send + '_>>
{
Box::pin(async {
Err(fraiseql_error::FraiseQLError::Unsupported {
message: "storage_put not available in snapshot context".to_string(),
})
})
}
fn auth_context(&self) -> fraiseql_error::Result<serde_json::Value> {
self.auth_context
.clone()
.map_err(|msg| fraiseql_error::FraiseQLError::Unsupported { message: msg })
}
fn env_var(&self, name: &str) -> fraiseql_error::Result<Option<String>> {
tracing::debug!(
var = name,
"env_var called on HostContextSnapshot — returning None; use invoke_with_context for env var support"
);
Ok(None)
}
fn event_payload(&self) -> &EventPayload {
&self.event_payload
}
fn log(&self, level: crate::types::LogLevel, message: &str) {
match level {
crate::types::LogLevel::Debug => tracing::debug!("{}", message),
crate::types::LogLevel::Info => tracing::info!("{}", message),
crate::types::LogLevel::Warn => tracing::warn!("{}", message),
crate::types::LogLevel::Error => tracing::error!("{}", message),
}
}
}
#[cfg(test)]
mod tests;