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}