Skip to main content

fraiseql_functions/runtime/deno/
mod.rs

1//! Deno (`JavaScript`/`TypeScript`) runtime for function execution via V8.
2//!
3//! This module provides `DenoRuntime`, which executes `JavaScript` and `TypeScript` functions
4//! using the Deno core runtime with embedded V8 isolates.
5//!
6//! # Architecture
7//!
8//! The Deno runtime is an opt-in feature (`runtime-deno`) due to V8's binary size (~30MB)
9//! and compile-time impact. When disabled, there is zero impact on compilation time or binary size.
10//!
11//! Each execution:
12//! 1. Creates a new V8 isolate with memory and timeout limits
13//! 2. Loads the function source. When [`DenoConfig::enable_typescript`] is set (the default),
14//!    `TypeScript` types are stripped to executable `JavaScript` first (see [`transpile`]); with it
15//!    off the isolate executes the `JavaScript` unchanged.
16//! 3. Calls the default export with the event as a JS object
17//! 4. Captures logs and enforces resource limits throughout
18//! 5. Properly cleans up the isolate after execution
19
20pub mod executor;
21pub mod ops;
22pub mod tests;
23pub mod transpile;
24pub mod watchdog;
25
26#[cfg(test)]
27mod follow_up_tests;
28#[cfg(test)]
29mod transpile_tests;
30// Uses the real `LiveHostContext` (host-live) to prove the op end-to-end, so it is
31// gated on host-live as well — the `runtime-deno`-only clippy combo has no live host.
32#[cfg(all(test, feature = "host-live"))]
33mod idempotency_tests;
34// Model B cursor ops end-to-end on a source-bound `LiveHostContext` (host-live) +
35// real Postgres; local-only like every deno test (V8 SIGSEGVs in the CI sandbox).
36#[cfg(all(test, feature = "host-live"))]
37mod cursor_tests;
38#[cfg(test)]
39mod qonto_tests;
40#[cfg(test)]
41mod reply_awareness_tests;
42#[cfg(test)]
43mod scoring_tests;
44
45use std::sync::Arc;
46
47use fraiseql_error::Result;
48
49use crate::{
50    HostContext,
51    host::dyn_context::DynHostContext,
52    runtime::FunctionRuntime,
53    types::{EventPayload, FunctionModule, FunctionResult, ResourceLimits},
54};
55
56/// `TypeScript` ambient declarations for the FraiseQL native-functions host surface.
57///
58/// The host operations are reached as `Deno.core.ops.fraiseql_*`, with
59/// `Deno.core.encode` / `Deno.core.decode` bridging strings and byte buffers. This
60/// is the canonical shape shipped to authors as `examples/native-functions/fraiseql-host.d.ts`
61/// (kept in sync with this constant); reference it from a function to get
62/// type-checking and editor autocomplete.
63pub const FRAISEQL_HOST_TYPES: &str = r"
64// FraiseQL native-functions host surface (Deno.core.ops.fraiseql_*).
65
66interface FraiseqlHttpResponse {
67  status: number;
68  headers: Array<[string, string]>;
69  body: Uint8Array;
70}
71
72interface FraiseqlHostOps {
73  // Execute a GraphQL query/mutation. `variables` is a JSON string; returns a JSON string.
74  fraiseql_query(graphql: string, variables: string): Promise<string>;
75  // NOT IMPLEMENTED (#871): no execution backend is wired — every call throws
76  // (statements are classified read-only/rejected, never executed). Use
77  // `fraiseql_query` instead.
78  fraiseql_sql_query(sql: string, params: string): Promise<string>;
79  // Make an outbound HTTP request (SSRF-allowlisted by the host).
80  fraiseql_http_request(
81    method: string,
82    url: string,
83    headers: Array<[string, string]>,
84    body: Uint8Array | null,
85  ): Promise<FraiseqlHttpResponse>;
86  // Object storage.
87  fraiseql_storage_get(bucket: string, key: string): Promise<Uint8Array>;
88  fraiseql_storage_put(
89    bucket: string,
90    key: string,
91    body: Uint8Array,
92    contentType: string,
93  ): Promise<void>;
94  // Send an email. `from` is host-owned; the request JSON carries only
95  // { to, subject, text?, html?, reply_to? }.
96  fraiseql_send_email(request: string): Promise<string>;
97  // The authenticated caller's context, as a JSON string.
98  fraiseql_auth_context(): string;
99  // Read a host-allowlisted environment variable (granted via
100  // FRAISEQL_FUNCTIONS_ALLOWED_ENV_VARS / [sources] allowed_env_vars). A
101  // non-allowlisted name THROWS; null means allowlisted but unset (#840).
102  fraiseql_env_var(name: string): string | null;
103  // Per-dispatch idempotency token, or null on a non-durably-dispatched invocation.
104  fraiseql_idempotency_token(): string | null;
105  // Scheduled-source cursor (Model B). `fraiseql_cursor_get` returns the JSON string
106  // the source last advanced to, or the string 'null'; `fraiseql_cursor_advance`
107  // persists any JSON value as the new cursor. Both fail on a non-source invocation.
108  fraiseql_cursor_get(): Promise<string>;
109  fraiseql_cursor_advance(valueJson: string): Promise<void>;
110  // Structured log. Levels: 0=debug, 1=info, 2=warn, 3=error.
111  fraiseql_log(level: number, message: string): void;
112}
113
114declare namespace Deno {
115  namespace core {
116    const ops: FraiseqlHostOps;
117    function encode(text: string): Uint8Array;
118    function decode(bytes: Uint8Array): string;
119  }
120}
121";
122
123/// Configuration for the Deno runtime.
124///
125/// Allows tuning of the V8 engine for performance and feature support.
126#[derive(Debug, Clone)]
127pub struct DenoConfig {
128    /// Strip `TypeScript` types to executable `JavaScript` before execution (see
129    /// [`transpile::transpile_typescript`]). On by default; with it off the source
130    /// is executed as-is, so only the type-annotation-free `TypeScript` subset runs.
131    pub enable_typescript: bool,
132    /// Additional V8 flags (e.g., "--expose-gc").
133    pub v8_flags:          Vec<String>,
134}
135
136impl Default for DenoConfig {
137    fn default() -> Self {
138        Self {
139            enable_typescript: true,
140            v8_flags:          vec![],
141        }
142    }
143}
144
145/// Deno runtime using V8 isolates for JavaScript/TypeScript execution.
146pub struct DenoRuntime {
147    config: DenoConfig,
148}
149
150impl std::fmt::Debug for DenoRuntime {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.debug_struct("DenoRuntime").field("config", &self.config).finish()
153    }
154}
155
156impl Clone for DenoRuntime {
157    fn clone(&self) -> Self {
158        Self {
159            config: self.config.clone(),
160        }
161    }
162}
163
164impl DenoRuntime {
165    /// Create a new Deno runtime with the given configuration.
166    ///
167    /// # Errors
168    ///
169    /// Returns `Err` if runtime initialization fails.
170    pub fn new(config: &DenoConfig) -> Result<Self> {
171        Ok(Self {
172            config: config.clone(),
173        })
174    }
175
176    /// Execute a `JavaScript`/`TypeScript` module with a full I/O-capable host context.
177    ///
178    /// Unlike [`FunctionRuntime::invoke`] — which runs the guest with no host, so
179    /// the `Deno.core.ops.fraiseql_*` I/O ops fail loud — this threads a live
180    /// [`DynHostContext`] into the V8 op-state, giving the guest outbound HTTP,
181    /// GraphQL `query`, storage, `env_var`, and `auth_context` at parity with the
182    /// WASM [`invoke_with_context`](crate::runtime::wasm::WasmRuntime::invoke_with_context)
183    /// path. The after:mutation dispatcher uses this so side-effecting `TypeScript`
184    /// functions can reach the network.
185    ///
186    /// # Errors
187    ///
188    /// Returns `Err` on syntax errors, runtime exceptions, resource-limit
189    /// violations, or a host op that fails (e.g. an SSRF-blocked request).
190    pub async fn invoke_with_context(
191        &self,
192        module: &FunctionModule,
193        event: EventPayload,
194        host_context: Arc<dyn DynHostContext>,
195        limits: ResourceLimits,
196    ) -> Result<FunctionResult> {
197        run_guest(module, event, limits, Some(host_context), self.config.enable_typescript).await
198    }
199}
200
201/// Run a guest module on a dedicated OS thread (with its own single-threaded Tokio
202/// runtime for deno's event loop), optionally wiring a live host context into the
203/// op-state. Shared by [`FunctionRuntime::invoke`] (no host) and
204/// [`DenoRuntime::invoke_with_context`] (live host).
205fn run_guest(
206    module: &FunctionModule,
207    event: EventPayload,
208    limits: ResourceLimits,
209    host: Option<Arc<dyn DynHostContext>>,
210    enable_typescript: bool,
211) -> impl std::future::Future<Output = Result<FunctionResult>> + Send {
212    let raw = String::from_utf8_lossy(&module.bytecode).to_string();
213    // With `enable_typescript` (the default) strip `TypeScript` types to executable
214    // `JavaScript` before the isolate sees the source; with it off the `JavaScript`
215    // runs byte-for-byte unchanged. A transpile failure is a located `SyntaxError`,
216    // mapped below to the same permanent 4xx a malformed guest already gets.
217    let source = if enable_typescript {
218        transpile::transpile_typescript(&raw)
219    } else {
220        Ok(raw)
221    };
222    // Functions receive the entity data, not the full internal EventPayload.
223    let event_data = event.data;
224
225    async move {
226        let source = source.map_err(|message| fraiseql_error::FraiseQLError::Validation {
227            message,
228            path: None,
229        })?;
230
231        // #970: pin the host's async I/O to THIS (owner) runtime before the
232        // guest thread exists. The guest's throwaway runtime dies with each
233        // invocation; any connection a host op created there (sqlx, reqwest
234        // keep-alive) would return to its shared pool bound to a dead reactor
235        // and hang the next user. Wrapping here routes every op onto the
236        // long-lived runtime that owns those pools. A host with no runtime
237        // context is refused loudly rather than left to fail intermittently.
238        let host = match host {
239            Some(h) => Some(
240                tokio::runtime::Handle::try_current()
241                    .map(|handle| {
242                        Arc::new(crate::host::runtime_pinned::RuntimePinnedHost::new(h, handle))
243                            as Arc<dyn DynHostContext>
244                    })
245                    .map_err(|_| fraiseql_error::FraiseQLError::Internal {
246                        message: "Deno host ops require a Tokio runtime context (#970): \
247                          invoke_with_context must be awaited on the server runtime"
248                            .to_string(),
249                        source:  None,
250                    })?,
251            ),
252            None => None,
253        };
254
255        let start = std::time::Instant::now();
256
257        let (tx, rx) = tokio::sync::oneshot::channel::<
258            std::result::Result<executor::ExecutionResult, String>,
259        >();
260
261        std::thread::spawn(move || {
262            let result = executor::run_in_dedicated_thread(&source, &event_data, &limits, host);
263            let _ = tx.send(result);
264        });
265
266        let exec_result = rx.await.map_err(|_| fraiseql_error::FraiseQLError::Internal {
267            message: "Deno executor thread crashed".to_string(),
268            source:  None,
269        })?;
270
271        // Measure AFTER the executor thread completes (M-deno-duration). Taking
272        // the elapsed time right after spawning measured only channel setup,
273        // not the actual script execution awaited on `rx`.
274        let duration = start.elapsed();
275
276        match exec_result {
277            Ok(execution_result) => Ok(FunctionResult {
278                value: Some(execution_result.value),
279                logs: execution_result.logs,
280                duration,
281                memory_peak_bytes: 0,
282            }),
283            Err(e) if e.starts_with("SyntaxError") => {
284                Err(fraiseql_error::FraiseQLError::Validation {
285                    message: e,
286                    path:    None,
287                })
288            },
289            // A permanent-tagged failure (a host op that returned a 4xx, or a guest
290            // that tagged its throw) maps to a 4xx so durable dispatch dead-letters
291            // immediately instead of retrying. Untagged failures stay `Unsupported`
292            // (501, transient).
293            Err(e) if e.contains(crate::types::PERMANENT_ERROR_MARKER) => {
294                Err(fraiseql_error::FraiseQLError::Validation {
295                    message: e,
296                    path:    None,
297                })
298            },
299            Err(e) => Err(fraiseql_error::FraiseQLError::Unsupported { message: e }),
300        }
301    }
302}
303
304impl FunctionRuntime for DenoRuntime {
305    /// Execute a `JavaScript` module with the given event and host context.
306    ///
307    /// # Implementation
308    ///
309    /// 1. Spawns a fresh OS thread (avoids tokio `block_on` nesting).
310    /// 2. That thread creates its own single-threaded Tokio runtime for deno's event loop.
311    /// 3. The `export default` function is called with `event.data` as the argument.
312    /// 4. The result and captured logs are returned via a `oneshot` channel.
313    ///
314    /// # Errors
315    ///
316    /// Returns `Err` on syntax errors, runtime exceptions, or resource-limit violations.
317    #[allow(clippy::manual_async_fn)] // Reason: impl Future syntax for trait compatibility
318    fn invoke<H>(
319        &self,
320        module: &FunctionModule,
321        event: EventPayload,
322        _host: &H,
323        limits: ResourceLimits,
324    ) -> impl std::future::Future<Output = Result<FunctionResult>> + Send
325    where
326        H: HostContext + ?Sized,
327    {
328        // The sync `invoke` path provides no live host: the guest may log and
329        // transform data, but any `fraiseql_*` I/O op fails loud. Use
330        // `invoke_with_context` for outbound HTTP / query / storage.
331        run_guest(module, event, limits, None, self.config.enable_typescript)
332    }
333
334    fn supported_extensions(&self) -> &[&str] {
335        &[".js", ".ts", ".mjs", ".mts"]
336    }
337
338    fn supports_hot_reload(&self) -> bool {
339        false
340    }
341
342    fn name(&self) -> &'static str {
343        "deno"
344    }
345}