Skip to main content

fraiseql_functions/runtime/wasm/
mod.rs

1//! `WebAssembly` Component Model runtime for function execution.
2//!
3//! This module provides `WasmRuntime`, which executes WASM components
4//! using the wasmtime engine with the Component Model.
5//!
6//! # Architecture
7//!
8//! The WASM runtime uses `wasmtime` with component model enabled to safely execute
9//! untrusted function modules with resource limits enforced. Each execution:
10//!
11//! 1. Loads a pre-compiled WASM component from bytecode
12//! 2. Creates a store with resource limiting callbacks
13//! 3. Instantiates the component with host-provided imports (logging, context, I/O)
14//! 4. Calls the exported `handle` function with the event as JSON
15//! 5. Collects logs and captures the result, enforcing resource limits throughout
16//!
17//! # WIT Interface
18//!
19//! Guest components implement the `fraiseql:host/fraiseql-function` world defined in
20//! `wit/fraiseql-host.wit`. The world provides:
21//! - `fraiseql:host/logging`: structured logging to the host
22//! - `fraiseql:host/context`: access to event payload, auth, environment
23//! - `fraiseql:host/io`: calls back to the host for queries, storage, HTTP
24
25pub mod bindings;
26pub mod host_bridge;
27pub mod store;
28
29use std::sync::Arc;
30
31use fraiseql_error::Result;
32
33use self::{host_bridge::DynHostContext, store::StoreData};
34use crate::{
35    HostContext,
36    runtime::FunctionRuntime,
37    types::{EventPayload, FunctionModule, FunctionResult, ResourceLimits},
38};
39
40/// Configuration for the WASM runtime.
41///
42/// Allows tuning of the wasmtime engine for performance and feature support.
43#[derive(Debug, Clone)]
44pub struct WasmConfig {
45    /// Enable SIMD (Single Instruction, Multiple Data) support in WASM modules.
46    ///
47    /// SIMD can improve performance for data-parallel workloads but adds compilation overhead.
48    pub enable_simd:           bool,
49    /// Optional directory for caching compiled components.
50    ///
51    /// If set, compiled modules are cached to disk to speed up subsequent loads.
52    pub compilation_cache_dir: Option<std::path::PathBuf>,
53}
54
55impl Default for WasmConfig {
56    fn default() -> Self {
57        Self {
58            enable_simd:           true,
59            compilation_cache_dir: None,
60        }
61    }
62}
63
64/// WASM runtime using wasmtime and the Component Model.
65pub struct WasmRuntime {
66    engine:       wasmtime::Engine,
67    /// Handle to the background epoch ticker thread.
68    /// Kept alive for the runtime's lifetime; dropped on `Drop`.
69    epoch_ticker: Arc<EpochTicker>,
70}
71
72/// Background thread that increments the engine epoch at a fixed interval.
73///
74/// When combined with `store.set_epoch_deadline()`, this preemptively interrupts
75/// runaway guests rather than relying on post-hoc duration checks.
76struct EpochTicker {
77    shutdown: std::sync::atomic::AtomicBool,
78    handle:   std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
79}
80
81impl EpochTicker {
82    /// Tick interval — each tick increments the epoch by 1.
83    const TICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
84
85    fn start(engine: wasmtime::Engine) -> Arc<Self> {
86        let ticker = Arc::new(Self {
87            shutdown: std::sync::atomic::AtomicBool::new(false),
88            handle:   std::sync::Mutex::new(None),
89        });
90
91        let ticker_clone = Arc::clone(&ticker);
92        let handle = std::thread::Builder::new()
93            .name("wasm-epoch-ticker".to_string())
94            .spawn(move || {
95                while !ticker_clone.shutdown.load(std::sync::atomic::Ordering::Relaxed) {
96                    std::thread::sleep(Self::TICK_INTERVAL);
97                    engine.increment_epoch();
98                }
99            })
100            .expect("failed to spawn epoch ticker thread");
101
102        *ticker.handle.lock().expect("lock") = Some(handle);
103        ticker
104    }
105}
106
107impl Drop for EpochTicker {
108    fn drop(&mut self) {
109        self.shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
110        let handle = self.handle.lock().expect("lock").take();
111        if let Some(handle) = handle {
112            let _ = handle.join();
113        }
114    }
115}
116
117impl std::fmt::Debug for WasmRuntime {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("WasmRuntime").finish()
120    }
121}
122
123impl Clone for WasmRuntime {
124    fn clone(&self) -> Self {
125        Self {
126            engine:       self.engine.clone(),
127            epoch_ticker: Arc::clone(&self.epoch_ticker),
128        }
129    }
130}
131
132impl WasmRuntime {
133    /// Create a new WASM runtime with the given configuration.
134    ///
135    /// # Errors
136    ///
137    /// Returns `Err` if engine initialization fails.
138    pub fn new(config: &WasmConfig) -> Result<Self> {
139        let mut wasm_config = wasmtime::Config::new();
140        wasm_config.epoch_interruption(true);
141        wasm_config.wasm_simd(config.enable_simd);
142        wasm_config.wasm_relaxed_simd(false);
143        wasm_config.wasm_bulk_memory(true);
144        wasm_config.wasm_component_model(true);
145
146        let engine = wasmtime::Engine::new(&wasm_config).map_err(|e| {
147            fraiseql_error::FraiseQLError::Validation {
148                message: format!("Failed to create WASM engine: {e}"),
149                path:    None,
150            }
151        })?;
152
153        let ticker = EpochTicker::start(engine.clone());
154
155        Ok(Self {
156            engine,
157            epoch_ticker: ticker,
158        })
159    }
160}
161
162impl WasmRuntime {
163    /// Execute a WASM component with a pre-built `Arc<dyn DynHostContext>`.
164    ///
165    /// This is the core invocation path. The generic `FunctionRuntime::invoke` wraps
166    /// the host into a `HostContextSnapshot`, which only supports sync ops. Use this
167    /// method directly when async IO ops (HTTP, query, storage) need to reach the
168    /// real host context.
169    ///
170    /// # Errors
171    ///
172    /// Returns `Err` if component loading, instantiation, or guest execution fails.
173    pub async fn invoke_with_context(
174        &self,
175        module: &FunctionModule,
176        event: EventPayload,
177        host_context: Arc<dyn DynHostContext>,
178        limits: ResourceLimits,
179    ) -> Result<FunctionResult> {
180        let start = std::time::Instant::now();
181        let timeout = limits.max_duration;
182
183        // Load the component from bytecode
184        let component = wasmtime::component::Component::new(&self.engine, &module.bytecode)
185            .map_err(|e| fraiseql_error::FraiseQLError::Validation {
186                message: format!("Failed to load WASM component: {e}"),
187                path:    None,
188            })?;
189
190        // Create store with host context
191        let mut store_data = StoreData::new(event, limits);
192        store_data.set_host_context(host_context);
193
194        let mut store = wasmtime::Store::new(&self.engine, store_data);
195
196        // Set up resource limiter (StoreData implements ResourceLimiter directly)
197        store.limiter(|data| data);
198
199        // Set epoch deadline for preemptive timeout.
200        // Each epoch tick is 100ms, so deadline = timeout_ms / 100.
201        let deadline_ticks = (timeout.as_millis() / EpochTicker::TICK_INTERVAL.as_millis()).max(1);
202        #[allow(clippy::cast_possible_truncation)]
203        // Reason: deadline clamped by max_duration which fits in u64
204        store.set_epoch_deadline(deadline_ticks as u64);
205
206        // Create linker and add host imports
207        let mut linker = wasmtime::component::Linker::new(&self.engine);
208
209        // Add WASI imports — guests compiled for wasm32-wasip2 need these
210        wasmtime_wasi::p2::add_to_linker_async(&mut linker).map_err(|e| {
211            fraiseql_error::FraiseQLError::Validation {
212                message: format!("Failed to link WASI imports: {e}"),
213                path:    None,
214            }
215        })?;
216
217        bindings::FraiseqlFunction::add_to_linker::<
218            StoreData,
219            wasmtime::component::HasSelf<StoreData>,
220        >(&mut linker, |data| data)
221        .map_err(|e| fraiseql_error::FraiseQLError::Validation {
222            message: format!("Failed to link host imports: {e}"),
223            path:    None,
224        })?;
225
226        // Instantiate the component using the low-level API so we can call
227        // the export asynchronously (the generated `call_handle` is sync-only,
228        // but async host imports require async dispatch).
229        let instance = linker.instantiate_async(&mut store, &component).await.map_err(|e| {
230            fraiseql_error::FraiseQLError::Internal {
231                message: format!("Failed to instantiate WASM component: {e}"),
232                source:  None,
233            }
234        })?;
235
236        // Serialize event to JSON for the guest
237        let event_json = serde_json::to_string(store.data().event_payload_ref())
238            .unwrap_or_else(|_| "{}".to_string());
239
240        // Get the handle export as a typed async-callable function.
241        let handle_func = instance
242            .get_typed_func::<(&str,), (std::result::Result<String, String>,)>(&mut store, "handle")
243            .map_err(|e| fraiseql_error::FraiseQLError::Internal {
244                message: format!("Failed to get handle export: {e}"),
245                source:  None,
246            })?;
247
248        let call_result = handle_func.call_async(&mut store, (&event_json,)).await;
249
250        let duration = start.elapsed();
251        let collected_logs = store.data().logs.clone();
252        let peak_memory = store.data().memory_peak_bytes;
253
254        let outcome = match call_result {
255            Ok((Ok(result_json),)) => GuestOutcome::Ok(result_json),
256            Ok((Err(error_msg),)) => GuestOutcome::GuestError(error_msg),
257            Err(trap) => {
258                let msg = trap.to_string();
259                if msg.contains("epoch deadline") || duration > timeout {
260                    GuestOutcome::Timeout
261                } else {
262                    GuestOutcome::Trap(msg)
263                }
264            },
265        };
266
267        let result_value = map_guest_outcome(outcome)?;
268
269        Ok(FunctionResult {
270            value: result_value,
271            logs: collected_logs,
272            duration,
273            memory_peak_bytes: peak_memory,
274        })
275    }
276}
277
278/// The outcome of calling the guest's `handle` export, normalised away from the
279/// wasmtime-specific call-result types so the failure contract can be unit-tested.
280#[derive(Debug, Clone, PartialEq, Eq)]
281enum GuestOutcome {
282    /// The guest returned `Ok(json)` — the JSON string it produced as data.
283    Ok(String),
284    /// The guest returned `Err(message)` — a guest-reported failure.
285    GuestError(String),
286    /// The guest execution timed out (epoch deadline / duration exceeded).
287    Timeout,
288    /// The guest trapped (panic, OOM, illegal instruction); carries the trap message.
289    Trap(String),
290}
291
292/// Map a guest outcome onto the shared runtime failure contract.
293///
294/// A successful guest return (`Ok`) yields `Ok(Some(value))`. Every failure mode
295/// — a guest-returned `Err`, a timeout, or a trap — yields
296/// `Err(FraiseQLError::Unsupported)`, matching what the Deno runtime returns for an
297/// equivalent guest runtime error (M-fn-failure-contract). A guest error must not
298/// be silently treated as successful data.
299fn map_guest_outcome(outcome: GuestOutcome) -> Result<Option<serde_json::Value>> {
300    match outcome {
301        GuestOutcome::Ok(result_json) => {
302            let value = serde_json::from_str(&result_json).unwrap_or_else(
303                |e| serde_json::json!({ "error": format!("invalid JSON from guest: {e}") }),
304            );
305            Ok(Some(value))
306        },
307        // A permanent-tagged guest error (a host op that returned a 4xx, or a guest
308        // that tagged its `Err`) maps to a 4xx so durable dispatch dead-letters
309        // immediately; untagged errors stay `Unsupported` (501, transient). Parity
310        // with the Deno runtime.
311        GuestOutcome::GuestError(message)
312            if message.contains(crate::types::PERMANENT_ERROR_MARKER) =>
313        {
314            Err(fraiseql_error::FraiseQLError::Validation {
315                message,
316                path: None,
317            })
318        },
319        GuestOutcome::GuestError(message) => {
320            Err(fraiseql_error::FraiseQLError::Unsupported { message })
321        },
322        GuestOutcome::Timeout => Err(fraiseql_error::FraiseQLError::Unsupported {
323            message: "function execution timed out".to_string(),
324        }),
325        GuestOutcome::Trap(msg) => Err(fraiseql_error::FraiseQLError::Unsupported {
326            message: format!("WASM trap: {msg}"),
327        }),
328    }
329}
330
331impl FunctionRuntime for WasmRuntime {
332    /// Execute a WASM component module with the given event and host context.
333    ///
334    /// For async IO operations, use `invoke_with_context()` directly with an
335    /// `Arc<dyn DynHostContext>` that delegates to the real backends.
336    ///
337    /// # Errors
338    ///
339    /// Returns `Err` if component loading, instantiation, or guest execution fails.
340    #[allow(clippy::manual_async_fn)] // Reason: impl Future syntax for trait compatibility
341    fn invoke<H>(
342        &self,
343        module: &FunctionModule,
344        event: EventPayload,
345        host: &H,
346        limits: ResourceLimits,
347    ) -> impl std::future::Future<Output = Result<FunctionResult>> + Send
348    where
349        H: HostContext + ?Sized,
350    {
351        // Snapshot captures sync state (auth_context, env_var, event_payload).
352        // Async IO ops will return Unsupported — use invoke_with_context for full IO.
353        let host_context: Arc<dyn DynHostContext> = Arc::from(HostContextSnapshot::capture(host));
354        let runtime = self.clone();
355        let module = module.clone();
356
357        async move { runtime.invoke_with_context(&module, event, host_context, limits).await }
358    }
359
360    fn supported_extensions(&self) -> &[&str] {
361        &[".wasm"]
362    }
363
364    fn supports_hot_reload(&self) -> bool {
365        false
366    }
367
368    fn name(&self) -> &'static str {
369        "wasm"
370    }
371}
372
373/// A snapshot of a `HostContext` that owns all its data, allowing `'static` lifetime.
374///
375/// This is necessary because `StoreData` needs `Arc<dyn DynHostContext>` which is `'static`,
376/// but the `invoke()` method receives `&H` with a borrowed lifetime.
377///
378/// # Limitations
379///
380/// - **Env vars**: Always returns `Ok(None)`. The `HostContext::env_var` method reads from the
381///   process environment filtered by an allowlist at call time, and we cannot capture that behavior
382///   without knowing which names the guest will request. Use `invoke_with_context()` for full env
383///   var support.
384/// - **Async IO**: `query`, `sql_query`, `http_request`, `storage_get`, `storage_put` return
385///   `Unsupported`. Use `invoke_with_context()` for full IO support.
386struct HostContextSnapshot {
387    event_payload: EventPayload,
388    /// Pre-captured auth context (Ok value or error message).
389    auth_context:  std::result::Result<serde_json::Value, String>,
390}
391
392impl HostContextSnapshot {
393    fn capture<H: HostContext + ?Sized>(host: &H) -> Self {
394        let event_payload = host.event_payload().clone();
395        let auth_context = host.auth_context().map_err(|e| e.to_string());
396
397        Self {
398            event_payload,
399            auth_context,
400        }
401    }
402}
403
404impl DynHostContext for HostContextSnapshot {
405    fn query(
406        &self,
407        _graphql: &str,
408        _variables: serde_json::Value,
409    ) -> std::pin::Pin<
410        Box<
411            dyn std::future::Future<Output = fraiseql_error::Result<serde_json::Value>> + Send + '_,
412        >,
413    > {
414        Box::pin(async {
415            Err(fraiseql_error::FraiseQLError::Unsupported {
416                message: "query not available in snapshot context".to_string(),
417            })
418        })
419    }
420
421    fn sql_query(
422        &self,
423        _sql: &str,
424        _params: &[serde_json::Value],
425    ) -> std::pin::Pin<
426        Box<
427            dyn std::future::Future<Output = fraiseql_error::Result<Vec<serde_json::Value>>>
428                + Send
429                + '_,
430        >,
431    > {
432        Box::pin(async {
433            Err(fraiseql_error::FraiseQLError::Unsupported {
434                message: "sql_query not available in snapshot context".to_string(),
435            })
436        })
437    }
438
439    fn http_request(
440        &self,
441        _method: &str,
442        _url: &str,
443        _headers: &[(String, String)],
444        _body: Option<&[u8]>,
445    ) -> std::pin::Pin<
446        Box<
447            dyn std::future::Future<Output = fraiseql_error::Result<crate::host::HttpResponse>>
448                + Send
449                + '_,
450        >,
451    > {
452        Box::pin(async {
453            Err(fraiseql_error::FraiseQLError::Unsupported {
454                message: "http_request not available in snapshot context".to_string(),
455            })
456        })
457    }
458
459    fn storage_get(
460        &self,
461        _bucket: &str,
462        _key: &str,
463    ) -> std::pin::Pin<
464        Box<dyn std::future::Future<Output = fraiseql_error::Result<Vec<u8>>> + Send + '_>,
465    > {
466        Box::pin(async {
467            Err(fraiseql_error::FraiseQLError::Unsupported {
468                message: "storage_get not available in snapshot context".to_string(),
469            })
470        })
471    }
472
473    fn storage_put(
474        &self,
475        _bucket: &str,
476        _key: &str,
477        _body: &[u8],
478        _content_type: &str,
479    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = fraiseql_error::Result<()>> + Send + '_>>
480    {
481        Box::pin(async {
482            Err(fraiseql_error::FraiseQLError::Unsupported {
483                message: "storage_put not available in snapshot context".to_string(),
484            })
485        })
486    }
487
488    fn send_email<'a>(
489        &'a self,
490        _request: &'a crate::outbound::SendEmailRequest,
491    ) -> std::pin::Pin<
492        Box<
493            dyn std::future::Future<
494                    Output = fraiseql_error::Result<crate::outbound::SendEmailResponse>,
495                > + Send
496                + 'a,
497        >,
498    > {
499        Box::pin(async {
500            Err(fraiseql_error::FraiseQLError::Unsupported {
501                message: "send_email not available in snapshot context".to_string(),
502            })
503        })
504    }
505
506    fn auth_context(&self) -> fraiseql_error::Result<serde_json::Value> {
507        self.auth_context
508            .clone()
509            .map_err(|msg| fraiseql_error::FraiseQLError::Unsupported { message: msg })
510    }
511
512    fn env_var(&self, name: &str) -> fraiseql_error::Result<Option<String>> {
513        tracing::debug!(
514            var = name,
515            "env_var called on HostContextSnapshot — returning None; use invoke_with_context for env var support"
516        );
517        Ok(None)
518    }
519
520    fn event_payload(&self) -> &EventPayload {
521        &self.event_payload
522    }
523
524    fn log(&self, level: crate::types::LogLevel, message: &str) {
525        match level {
526            crate::types::LogLevel::Debug => tracing::debug!("{}", message),
527            crate::types::LogLevel::Info => tracing::info!("{}", message),
528            crate::types::LogLevel::Warn => tracing::warn!("{}", message),
529            crate::types::LogLevel::Error => tracing::error!("{}", message),
530        }
531    }
532}
533
534#[cfg(test)]
535mod tests;