fraiseql_functions/runtime/wasm/
mod.rs1pub 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#[derive(Debug, Clone)]
44pub struct WasmConfig {
45 pub enable_simd: bool,
49 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
64pub struct WasmRuntime {
66 engine: wasmtime::Engine,
67 epoch_ticker: Arc<EpochTicker>,
70}
71
72struct EpochTicker {
77 shutdown: std::sync::atomic::AtomicBool,
78 handle: std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
79}
80
81impl EpochTicker {
82 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 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 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 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 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 store.limiter(|data| data);
198
199 let deadline_ticks = (timeout.as_millis() / EpochTicker::TICK_INTERVAL.as_millis()).max(1);
202 #[allow(clippy::cast_possible_truncation)]
203 store.set_epoch_deadline(deadline_ticks as u64);
205
206 let mut linker = wasmtime::component::Linker::new(&self.engine);
208
209 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 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 let event_json = serde_json::to_string(store.data().event_payload_ref())
238 .unwrap_or_else(|_| "{}".to_string());
239
240 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#[derive(Debug, Clone, PartialEq, Eq)]
281enum GuestOutcome {
282 Ok(String),
284 GuestError(String),
286 Timeout,
288 Trap(String),
290}
291
292fn 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 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 #[allow(clippy::manual_async_fn)] 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 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
373struct HostContextSnapshot {
387 event_payload: EventPayload,
388 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;