Skip to main content

haste_operation_executor/providers/deno_embedded/
mod.rs

1use deno_core::cppgc::GcCell;
2use deno_core::error::ModuleLoaderError;
3
4pub mod pool;
5
6use deno_core::{
7    Extension, GarbageCollected, ModuleLoadOptions, ModuleLoadReferrer, ModuleLoader, ModuleType,
8    OpState, PollEventLoopOptions, op2, resolve_import, serde_json, v8,
9};
10// main.rs
11use deno_core::error::AnyError;
12use haste_fhir_client::FHIRClient;
13use haste_fhir_client::url::ParsedParameters;
14use haste_fhir_model::r4::generated::resources::{Bundle, Parameters, Resource, ResourceType};
15use haste_fhir_operation_error::OperationOutcomeError;
16use json_patch::Patch;
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::rc::Rc;
20use std::sync::{Arc, LazyLock};
21use std::time::Duration;
22use tokio::sync::Mutex;
23
24use deno_ast::{MediaType, ModuleSpecifier};
25use deno_ast::{ParseParams, SourceMapOption};
26use deno_core::ModuleLoadResponse;
27use deno_error::JsErrorBox;
28
29pub use pool::DenoPool;
30
31use crate::structs::PluginCodeType;
32
33/// Hard ceiling on how long a single custom-operation script may run before
34/// its isolate is forcibly terminated.
35///
36/// V8 execution is fully synchronous from Rust's point of view: a runaway
37/// script (e.g. an infinite loop) never yields back to the async executor,
38/// so `tokio::time::timeout` cannot interrupt it. Instead an
39/// [`ExecutionWatchdog`] on a separate OS thread calls
40/// `IsolateHandle::terminate_execution`, which V8 supports specifically for
41/// this purpose.
42const EXECUTION_TIMEOUT: Duration = Duration::from_secs(10);
43
44/// Hard ceiling on the V8 heap a single custom-operation script may grow to
45/// before its isolate is forcibly terminated. Without this, one tenant's
46/// script can exhaust memory shared by every other tenant's executions on
47/// the same process.
48const MAX_HEAP_BYTES: usize = 128 * 1024 * 1024;
49
50/// Custom-operation source rarely changes between invocations of the same
51/// operation, so cache the transpiled output keyed by (declared media type,
52/// raw source). This is a pure function of its inputs -- caching it cannot
53/// leak state between runs, it only avoids redundant TypeScript parsing on
54/// every call. Bounded to avoid unbounded growth if many distinct scripts
55/// are ever seen.
56const TRANSPILE_CACHE_CAPACITY: usize = 256;
57
58type TranspileCacheKey = (PluginCodeType, String);
59
60static TRANSPILE_CACHE: LazyLock<
61    std::sync::Mutex<HashMap<TranspileCacheKey, (ModuleType, String)>>,
62> = LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
63
64impl From<PluginCodeType> for MediaType {
65    fn from(value: PluginCodeType) -> Self {
66        match value {
67            PluginCodeType::JavaScript => MediaType::JavaScript,
68            PluginCodeType::TypeScript => MediaType::TypeScript,
69        }
70    }
71}
72
73// Load the snapshot generated by build.rs:
74static RUNTIME_SNAPSHOT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/RUNJS_SNAPSHOT.bin"));
75
76fn transpile_code_to_js(
77    module_specifier: &ModuleSpecifier,
78    media_type: MediaType,
79    code: &str,
80) -> Result<(ModuleType, String), JsErrorBox> {
81    let (module_type, should_transpile) = match media_type {
82        MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => (ModuleType::JavaScript, false),
83        MediaType::Jsx
84        | MediaType::TypeScript
85        | MediaType::Mts
86        | MediaType::Cts
87        | MediaType::Dts
88        | MediaType::Dmts
89        | MediaType::Dcts
90        | MediaType::Tsx => (ModuleType::JavaScript, true),
91        MediaType::Json => (ModuleType::Json, false),
92        _ => {
93            return Err(JsErrorBox::generic(format!(
94                "Unknown extension {media_type:?}",
95            )));
96        }
97    };
98    if should_transpile {
99        let parsed = deno_ast::parse_module(ParseParams {
100            specifier: module_specifier.clone(),
101            text: code.into(),
102            media_type,
103            capture_tokens: false,
104            scope_analysis: false,
105            maybe_syntax: None,
106        })
107        .map_err(JsErrorBox::from_err)?;
108        let res = parsed
109            .transpile(
110                &deno_ast::TranspileOptions {
111                    imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Remove,
112                    decorators: deno_ast::DecoratorsTranspileOption::Ecma,
113                    ..Default::default()
114                },
115                &deno_ast::TranspileModuleOptions { module_kind: None },
116                &deno_ast::EmitOptions {
117                    source_map: SourceMapOption::Separate,
118                    inline_sources: true,
119                    ..Default::default()
120                },
121            )
122            .map_err(JsErrorBox::from_err)?;
123        let res = res.into_source();
124        // let source_map = res.source_map.unwrap().into_bytes();
125
126        Ok((module_type, res.text))
127    } else {
128        Ok((module_type, code.to_string()))
129    }
130}
131
132/// Resolves import specifiers but never actually loads anything from disk or
133/// the network.
134struct TsModuleLoader;
135impl ModuleLoader for TsModuleLoader {
136    fn resolve(
137        &self,
138        specifier: &str,
139        referrer: &str,
140        _kind: deno_core::ResolutionKind,
141    ) -> Result<deno_core::ModuleSpecifier, ModuleLoaderError> {
142        resolve_import(specifier, referrer).map_err(JsErrorBox::from_err)
143    }
144
145    fn load(
146        &self,
147        _module_specifier: &ModuleSpecifier,
148        _maybe_referrer: Option<&ModuleLoadReferrer>,
149        _options: ModuleLoadOptions,
150    ) -> ModuleLoadResponse {
151        ModuleLoadResponse::Sync(Err(JsErrorBox::generic(
152            "Imports are not supported in custom operation scripts.",
153        )))
154    }
155}
156
157/// Transpiles `code` to JS, reusing a cached result when the same
158/// `(media type, source)` pair has already been transpiled.
159fn cached_transpile_to_js(
160    plugin_code_type: PluginCodeType,
161    code: &str,
162) -> Result<(ModuleType, String), JsErrorBox> {
163    let key = (plugin_code_type, code.to_string());
164
165    if let Some(cached) = TRANSPILE_CACHE
166        .lock()
167        .unwrap_or_else(std::sync::PoisonError::into_inner)
168        .get(&key)
169    {
170        return Ok(cached.clone());
171    }
172
173    let user_module_specifier = ModuleSpecifier::parse("memo://user.ts").unwrap();
174    let result = transpile_code_to_js(&user_module_specifier, plugin_code_type.into(), code)?;
175
176    let mut cache = TRANSPILE_CACHE
177        .lock()
178        .unwrap_or_else(std::sync::PoisonError::into_inner);
179    if cache.len() >= TRANSPILE_CACHE_CAPACITY {
180        cache.clear();
181    }
182    cache.insert(key, result.clone());
183
184    Ok(result)
185}
186
187/// Runs a callback on a dedicated OS thread that forcibly terminates a V8
188/// isolate if it doesn't finish within a deadline.
189///
190/// V8's `run_event_loop` blocks the calling thread for as long as the script
191/// keeps the event loop busy; a script stuck in an infinite loop never polls
192/// again, so nothing on that same thread (including a `tokio::time::sleep`)
193/// can ever run to interrupt it. `IsolateHandle::terminate_execution` is
194/// V8's supported mechanism for interrupting a busy isolate from another
195/// thread, so the watchdog has to live on one.
196struct ExecutionWatchdog {
197    stop_tx: Option<std::sync::mpsc::Sender<()>>,
198    thread: Option<std::thread::JoinHandle<()>>,
199}
200
201impl ExecutionWatchdog {
202    fn start(isolate_handle: v8::IsolateHandle, timeout: Duration) -> Self {
203        let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
204
205        let thread = std::thread::spawn(move || {
206            if matches!(
207                stop_rx.recv_timeout(timeout),
208                Err(std::sync::mpsc::RecvTimeoutError::Timeout)
209            ) {
210                isolate_handle.terminate_execution();
211            }
212        });
213
214        Self {
215            stop_tx: Some(stop_tx),
216            thread: Some(thread),
217        }
218    }
219}
220
221impl Drop for ExecutionWatchdog {
222    fn drop(&mut self) {
223        if let Some(stop_tx) = self.stop_tx.take() {
224            let _ = stop_tx.send(());
225        }
226        if let Some(thread) = self.thread.take() {
227            let _ = thread.join();
228        }
229    }
230}
231
232struct JSRuntimeState<CTX, Client: FHIRClient<CTX, OperationOutcomeError>> {
233    fhir_client: Arc<Client>,
234    ctx: CTX,
235    return_value: Option<serde_json::Value>,
236    input: Rc<serde_json::Value>,
237}
238
239#[repr(C)]
240pub struct InteropObject {
241    value: GcCell<f64>,
242}
243
244unsafe impl GarbageCollected for InteropObject {
245    fn trace(&self, _visitor: &mut v8::cppgc::Visitor) {}
246    fn get_name(&self) -> &'static std::ffi::CStr {
247        c"InteropObject"
248    }
249}
250
251#[op2]
252impl InteropObject {
253    #[constructor]
254    #[cppgc]
255    fn new(value: f64) -> InteropObject {
256        InteropObject {
257            value: GcCell::new(value),
258        }
259    }
260
261    #[getter]
262    fn value(&self, isolate: &v8::Isolate) -> f64 {
263        *self.value.get(isolate)
264    }
265
266    #[setter]
267    fn value(&self, isolate: &mut v8::Isolate, value: f64) {
268        self.value.set(isolate, value);
269    }
270
271    // #[fast]
272    // fn double_value(&self, isolate: &v8::Isolate) -> f64 {
273    //     *self.value.get(isolate) * 2.0
274    // }
275
276    #[static_method]
277    #[cppgc]
278    fn create(value: f64) -> InteropObject {
279        InteropObject {
280            value: GcCell::new(value),
281        }
282    }
283}
284
285/// Fetches the shared per-invocation state that every op needs to reach the
286/// tenant's `FHIRClient` and request context.
287fn runtime_state<CTX: Clone + 'static, Client: FHIRClient<CTX, OperationOutcomeError> + 'static>(
288    state: &Rc<RefCell<OpState>>,
289) -> Arc<Mutex<JSRuntimeState<CTX, Client>>> {
290    state
291        .borrow()
292        .borrow::<Arc<Mutex<JSRuntimeState<CTX, Client>>>>()
293        .clone()
294}
295
296fn parse_resource_type(resource_type: String) -> Result<ResourceType, JsErrorBox> {
297    ResourceType::try_from(resource_type)
298        .map_err(|_| JsErrorBox::type_error("Invalid resource type"))
299}
300
301/// Parses a FHIR search query string (e.g. `"name=Smith&_count=10"`, with or
302/// without a leading `?`) into the parameter list `FHIRClient` expects.
303fn parse_query(query: &str) -> Result<ParsedParameters, JsErrorBox> {
304    ParsedParameters::try_from(query)
305        .map_err(|error| JsErrorBox::type_error(format!("Invalid search parameters: {error}")))
306}
307
308fn parse_body<T: serde::de::DeserializeOwned>(
309    value: serde_json::Value,
310    what: &str,
311) -> Result<T, JsErrorBox> {
312    serde_json::from_value(value)
313        .map_err(|error| JsErrorBox::type_error(format!("Invalid {what}: {error}")))
314}
315
316fn to_json<T: serde::Serialize>(value: &T) -> Result<serde_json::Value, JsErrorBox> {
317    serde_json::to_value(value).map_err(|_| JsErrorBox::type_error("Failed to serialize response"))
318}
319
320/// Logs the underlying `OperationOutcomeError` and surfaces its diagnostics
321/// to the script -- these errors are already meant to be shown to FHIR API
322/// callers, so passing the message through gives script authors an
323/// actionable reason their operation failed.
324fn map_fhir_error(operation: &'static str) -> impl FnOnce(OperationOutcomeError) -> JsErrorBox {
325    move |error| {
326        tracing::error!(error = ?error, operation, "FHIR operation failed in custom operation script");
327        JsErrorBox::type_error(error.to_string())
328    }
329}
330
331#[op2]
332#[serde]
333pub async fn fhir_capabilities<
334    CTX: Clone + 'static,
335    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
336>(
337    state: Rc<RefCell<OpState>>,
338) -> Result<serde_json::Value, JsErrorBox> {
339    let app_state = runtime_state::<CTX, Client>(&state);
340    let app_state = app_state.lock().await;
341
342    let capabilities = app_state
343        .fhir_client
344        .capabilities(app_state.ctx.clone())
345        .await
346        .map_err(map_fhir_error("capabilities"))?;
347
348    to_json(&capabilities)
349}
350
351#[op2]
352#[serde]
353pub async fn fhir_search_type<
354    CTX: Clone + 'static,
355    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
356>(
357    state: Rc<RefCell<OpState>>,
358    #[string] resource_type: String,
359    #[string] query: String,
360) -> Result<serde_json::Value, JsErrorBox> {
361    let resource_type = parse_resource_type(resource_type)?;
362    let query = parse_query(&query)?;
363    let app_state = runtime_state::<CTX, Client>(&state);
364    let app_state = app_state.lock().await;
365
366    let bundle = app_state
367        .fhir_client
368        .search_type(app_state.ctx.clone(), resource_type, query)
369        .await
370        .map_err(map_fhir_error("searchType"))?;
371
372    to_json(&bundle)
373}
374
375#[op2]
376#[serde]
377pub async fn fhir_search_system<
378    CTX: Clone + 'static,
379    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
380>(
381    state: Rc<RefCell<OpState>>,
382    #[string] query: String,
383) -> Result<serde_json::Value, JsErrorBox> {
384    let query = parse_query(&query)?;
385    let app_state = runtime_state::<CTX, Client>(&state);
386    let app_state = app_state.lock().await;
387
388    let bundle = app_state
389        .fhir_client
390        .search_system(app_state.ctx.clone(), query)
391        .await
392        .map_err(map_fhir_error("searchSystem"))?;
393
394    to_json(&bundle)
395}
396
397#[op2]
398#[serde]
399pub async fn fhir_create<
400    CTX: Clone + 'static,
401    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
402>(
403    state: Rc<RefCell<OpState>>,
404    #[string] resource_type: String,
405    #[serde] resource: serde_json::Value,
406) -> Result<serde_json::Value, JsErrorBox> {
407    let resource_type = parse_resource_type(resource_type)?;
408    let resource: Resource = parse_body(resource, "resource")?;
409    let app_state = runtime_state::<CTX, Client>(&state);
410    let app_state = app_state.lock().await;
411
412    let created = app_state
413        .fhir_client
414        .create(app_state.ctx.clone(), resource_type, resource)
415        .await
416        .map_err(map_fhir_error("create"))?;
417
418    to_json(&created)
419}
420
421#[op2]
422#[serde]
423pub async fn fhir_update<
424    CTX: Clone + 'static,
425    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
426>(
427    state: Rc<RefCell<OpState>>,
428    #[string] resource_type: String,
429    #[string] id: String,
430    #[serde] resource: serde_json::Value,
431) -> Result<serde_json::Value, JsErrorBox> {
432    let resource_type = parse_resource_type(resource_type)?;
433    let resource: Resource = parse_body(resource, "resource")?;
434    let app_state = runtime_state::<CTX, Client>(&state);
435    let app_state = app_state.lock().await;
436
437    let updated = app_state
438        .fhir_client
439        .update(app_state.ctx.clone(), resource_type, id, resource)
440        .await
441        .map_err(map_fhir_error("update"))?;
442
443    to_json(&updated)
444}
445
446#[op2]
447#[serde]
448pub async fn fhir_conditional_update<
449    CTX: Clone + 'static,
450    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
451>(
452    state: Rc<RefCell<OpState>>,
453    #[string] resource_type: String,
454    #[string] query: String,
455    #[serde] resource: serde_json::Value,
456) -> Result<serde_json::Value, JsErrorBox> {
457    let resource_type = parse_resource_type(resource_type)?;
458    let query = parse_query(&query)?;
459    let resource: Resource = parse_body(resource, "resource")?;
460    let app_state = runtime_state::<CTX, Client>(&state);
461    let app_state = app_state.lock().await;
462
463    let updated = app_state
464        .fhir_client
465        .conditional_update(app_state.ctx.clone(), resource_type, query, resource)
466        .await
467        .map_err(map_fhir_error("conditionalUpdate"))?;
468
469    to_json(&updated)
470}
471
472#[op2]
473#[serde]
474pub async fn fhir_patch<
475    CTX: Clone + 'static,
476    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
477>(
478    state: Rc<RefCell<OpState>>,
479    #[string] resource_type: String,
480    #[string] id: String,
481    #[serde] patch: serde_json::Value,
482) -> Result<serde_json::Value, JsErrorBox> {
483    let resource_type = parse_resource_type(resource_type)?;
484    let patch: Patch = parse_body(patch, "patch")?;
485    let app_state = runtime_state::<CTX, Client>(&state);
486    let app_state = app_state.lock().await;
487
488    let patched = app_state
489        .fhir_client
490        .patch(app_state.ctx.clone(), resource_type, id, patch)
491        .await
492        .map_err(map_fhir_error("patch"))?;
493
494    to_json(&patched)
495}
496
497#[op2]
498#[serde]
499pub async fn fhir_read<
500    CTX: Clone + 'static,
501    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
502>(
503    state: Rc<RefCell<OpState>>,
504    #[string] resource_type: String,
505    #[string] id: String,
506) -> Result<serde_json::Value, JsErrorBox> {
507    let resource_type = parse_resource_type(resource_type)?;
508    let app_state = runtime_state::<CTX, Client>(&state);
509    let app_state = app_state.lock().await;
510
511    let resource = app_state
512        .fhir_client
513        .read(app_state.ctx.clone(), resource_type, id)
514        .await
515        .map_err(map_fhir_error("read"))?;
516
517    match resource {
518        Some(resource) => to_json(&resource),
519        None => Ok(serde_json::Value::Null),
520    }
521}
522
523#[op2]
524#[serde]
525pub async fn fhir_vread<
526    CTX: Clone + 'static,
527    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
528>(
529    state: Rc<RefCell<OpState>>,
530    #[string] resource_type: String,
531    #[string] id: String,
532    #[string] version_id: String,
533) -> Result<serde_json::Value, JsErrorBox> {
534    let resource_type = parse_resource_type(resource_type)?;
535    let app_state = runtime_state::<CTX, Client>(&state);
536    let app_state = app_state.lock().await;
537
538    let resource = app_state
539        .fhir_client
540        .vread(app_state.ctx.clone(), resource_type, id, version_id)
541        .await
542        .map_err(map_fhir_error("vread"))?;
543
544    match resource {
545        Some(resource) => to_json(&resource),
546        None => Ok(serde_json::Value::Null),
547    }
548}
549
550#[op2]
551pub async fn fhir_delete_instance<
552    CTX: Clone + 'static,
553    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
554>(
555    state: Rc<RefCell<OpState>>,
556    #[string] resource_type: String,
557    #[string] id: String,
558) -> Result<(), JsErrorBox> {
559    let resource_type = parse_resource_type(resource_type)?;
560    let app_state = runtime_state::<CTX, Client>(&state);
561    let app_state = app_state.lock().await;
562
563    app_state
564        .fhir_client
565        .delete_instance(app_state.ctx.clone(), resource_type, id)
566        .await
567        .map_err(map_fhir_error("deleteInstance"))
568}
569
570#[op2]
571pub async fn fhir_delete_type<
572    CTX: Clone + 'static,
573    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
574>(
575    state: Rc<RefCell<OpState>>,
576    #[string] resource_type: String,
577    #[string] query: String,
578) -> Result<(), JsErrorBox> {
579    let resource_type = parse_resource_type(resource_type)?;
580    let query = parse_query(&query)?;
581    let app_state = runtime_state::<CTX, Client>(&state);
582    let app_state = app_state.lock().await;
583
584    app_state
585        .fhir_client
586        .delete_type(app_state.ctx.clone(), resource_type, query)
587        .await
588        .map_err(map_fhir_error("deleteType"))
589}
590
591#[op2]
592pub async fn fhir_delete_system<
593    CTX: Clone + 'static,
594    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
595>(
596    state: Rc<RefCell<OpState>>,
597    #[string] query: String,
598) -> Result<(), JsErrorBox> {
599    let query = parse_query(&query)?;
600    let app_state = runtime_state::<CTX, Client>(&state);
601    let app_state = app_state.lock().await;
602
603    app_state
604        .fhir_client
605        .delete_system(app_state.ctx.clone(), query)
606        .await
607        .map_err(map_fhir_error("deleteSystem"))
608}
609
610#[op2]
611#[serde]
612pub async fn fhir_history_instance<
613    CTX: Clone + 'static,
614    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
615>(
616    state: Rc<RefCell<OpState>>,
617    #[string] resource_type: String,
618    #[string] id: String,
619    #[string] query: String,
620) -> Result<serde_json::Value, JsErrorBox> {
621    let resource_type = parse_resource_type(resource_type)?;
622    let query = parse_query(&query)?;
623    let app_state = runtime_state::<CTX, Client>(&state);
624    let app_state = app_state.lock().await;
625
626    let bundle = app_state
627        .fhir_client
628        .history_instance(app_state.ctx.clone(), resource_type, id, query)
629        .await
630        .map_err(map_fhir_error("historyInstance"))?;
631
632    to_json(&bundle)
633}
634
635#[op2]
636#[serde]
637pub async fn fhir_history_type<
638    CTX: Clone + 'static,
639    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
640>(
641    state: Rc<RefCell<OpState>>,
642    #[string] resource_type: String,
643    #[string] query: String,
644) -> Result<serde_json::Value, JsErrorBox> {
645    let resource_type = parse_resource_type(resource_type)?;
646    let query = parse_query(&query)?;
647    let app_state = runtime_state::<CTX, Client>(&state);
648    let app_state = app_state.lock().await;
649
650    let bundle = app_state
651        .fhir_client
652        .history_type(app_state.ctx.clone(), resource_type, query)
653        .await
654        .map_err(map_fhir_error("historyType"))?;
655
656    to_json(&bundle)
657}
658
659#[op2]
660#[serde]
661pub async fn fhir_history_system<
662    CTX: Clone + 'static,
663    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
664>(
665    state: Rc<RefCell<OpState>>,
666    #[string] query: String,
667) -> Result<serde_json::Value, JsErrorBox> {
668    let query = parse_query(&query)?;
669    let app_state = runtime_state::<CTX, Client>(&state);
670    let app_state = app_state.lock().await;
671
672    let bundle = app_state
673        .fhir_client
674        .history_system(app_state.ctx.clone(), query)
675        .await
676        .map_err(map_fhir_error("historySystem"))?;
677
678    to_json(&bundle)
679}
680
681#[op2]
682#[serde]
683pub async fn fhir_invoke_instance<
684    CTX: Clone + 'static,
685    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
686>(
687    state: Rc<RefCell<OpState>>,
688    #[string] resource_type: String,
689    #[string] id: String,
690    #[string] operation: String,
691    #[serde] parameters: serde_json::Value,
692) -> Result<serde_json::Value, JsErrorBox> {
693    let resource_type = parse_resource_type(resource_type)?;
694    let parameters: Parameters = parse_body(parameters, "parameters")?;
695    let app_state = runtime_state::<CTX, Client>(&state);
696    let app_state = app_state.lock().await;
697
698    let result = app_state
699        .fhir_client
700        .invoke_instance(
701            app_state.ctx.clone(),
702            resource_type,
703            id,
704            operation,
705            parameters,
706        )
707        .await
708        .map_err(map_fhir_error("invokeInstance"))?;
709
710    to_json(&result)
711}
712
713#[op2]
714#[serde]
715pub async fn fhir_invoke_type<
716    CTX: Clone + 'static,
717    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
718>(
719    state: Rc<RefCell<OpState>>,
720    #[string] resource_type: String,
721    #[string] operation: String,
722    #[serde] parameters: serde_json::Value,
723) -> Result<serde_json::Value, JsErrorBox> {
724    let resource_type = parse_resource_type(resource_type)?;
725    let parameters: Parameters = parse_body(parameters, "parameters")?;
726    let app_state = runtime_state::<CTX, Client>(&state);
727    let app_state = app_state.lock().await;
728
729    let result = app_state
730        .fhir_client
731        .invoke_type(app_state.ctx.clone(), resource_type, operation, parameters)
732        .await
733        .map_err(map_fhir_error("invokeType"))?;
734
735    to_json(&result)
736}
737
738#[op2]
739#[serde]
740pub async fn fhir_invoke_system<
741    CTX: Clone + 'static,
742    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
743>(
744    state: Rc<RefCell<OpState>>,
745    #[string] operation: String,
746    #[serde] parameters: serde_json::Value,
747) -> Result<serde_json::Value, JsErrorBox> {
748    let parameters: Parameters = parse_body(parameters, "parameters")?;
749    let app_state = runtime_state::<CTX, Client>(&state);
750    let app_state = app_state.lock().await;
751
752    let result = app_state
753        .fhir_client
754        .invoke_system(app_state.ctx.clone(), operation, parameters)
755        .await
756        .map_err(map_fhir_error("invokeSystem"))?;
757
758    to_json(&result)
759}
760
761#[op2]
762#[serde]
763pub async fn fhir_transaction<
764    CTX: Clone + 'static,
765    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
766>(
767    state: Rc<RefCell<OpState>>,
768    #[serde] bundle: serde_json::Value,
769) -> Result<serde_json::Value, JsErrorBox> {
770    let bundle: Bundle = parse_body(bundle, "bundle")?;
771    let app_state = runtime_state::<CTX, Client>(&state);
772    let app_state = app_state.lock().await;
773
774    let result = app_state
775        .fhir_client
776        .transaction(app_state.ctx.clone(), bundle)
777        .await
778        .map_err(map_fhir_error("transaction"))?;
779
780    to_json(&result)
781}
782
783#[op2]
784#[serde]
785pub async fn fhir_batch<
786    CTX: Clone + 'static,
787    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
788>(
789    state: Rc<RefCell<OpState>>,
790    #[serde] bundle: serde_json::Value,
791) -> Result<serde_json::Value, JsErrorBox> {
792    let bundle: Bundle = parse_body(bundle, "bundle")?;
793    let app_state = runtime_state::<CTX, Client>(&state);
794    let app_state = app_state.lock().await;
795
796    let result = app_state
797        .fhir_client
798        .batch(app_state.ctx.clone(), bundle)
799        .await
800        .map_err(map_fhir_error("batch"))?;
801
802    to_json(&result)
803}
804
805#[op2]
806#[serde]
807pub async fn set_return_value<
808    CTX: Clone + 'static,
809    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
810>(
811    state: Rc<RefCell<OpState>>,
812    #[serde] value: serde_json::Value,
813) -> Result<(), JsErrorBox> {
814    let app_state = runtime_state::<CTX, Client>(&state);
815    let mut app_state = app_state.lock().await;
816
817    app_state.return_value = Some(value);
818    Ok(())
819}
820
821#[op2]
822#[serde]
823pub async fn get_input_value<
824    CTX: Clone + 'static,
825    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
826>(
827    state: Rc<RefCell<OpState>>,
828) -> Result<serde_json::Value, JsErrorBox> {
829    let app_state = runtime_state::<CTX, Client>(&state);
830    let app_state = app_state.lock().await;
831
832    Ok(serde_json::json!({
833        "request": app_state.input.clone(),
834    }))
835}
836
837/// Builds a fresh, never-yet-used V8 isolate wired up with the ops a
838/// custom-operation script needs, but does not run anything against it.
839fn build_deno_runtime<
840    CTX: Clone + 'static,
841    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
842>() -> deno_core::JsRuntime {
843    let runjs = Extension {
844        name: "runjs",
845        ops: std::borrow::Cow::Owned(vec![
846            fhir_capabilities::<CTX, Client>(),
847            fhir_search_type::<CTX, Client>(),
848            fhir_search_system::<CTX, Client>(),
849            fhir_create::<CTX, Client>(),
850            fhir_update::<CTX, Client>(),
851            fhir_conditional_update::<CTX, Client>(),
852            fhir_patch::<CTX, Client>(),
853            fhir_read::<CTX, Client>(),
854            fhir_vread::<CTX, Client>(),
855            fhir_delete_instance::<CTX, Client>(),
856            fhir_delete_type::<CTX, Client>(),
857            fhir_delete_system::<CTX, Client>(),
858            fhir_history_instance::<CTX, Client>(),
859            fhir_history_type::<CTX, Client>(),
860            fhir_history_system::<CTX, Client>(),
861            fhir_invoke_instance::<CTX, Client>(),
862            fhir_invoke_type::<CTX, Client>(),
863            fhir_invoke_system::<CTX, Client>(),
864            fhir_transaction::<CTX, Client>(),
865            fhir_batch::<CTX, Client>(),
866            set_return_value::<CTX, Client>(),
867            get_input_value::<CTX, Client>(),
868        ]),
869        ..Default::default()
870    };
871
872    let create_params = v8::Isolate::create_params().heap_limits(0, MAX_HEAP_BYTES);
873
874    deno_core::JsRuntime::new(deno_core::RuntimeOptions {
875        module_loader: Some(Rc::new(TsModuleLoader)),
876        extensions: vec![runjs],
877        startup_snapshot: Some(RUNTIME_SNAPSHOT),
878        create_params: Some(create_params),
879        ..Default::default()
880    })
881}
882
883async fn run_code<
884    CTX: Clone + 'static,
885    Client: FHIRClient<CTX, OperationOutcomeError> + 'static,
886>(
887    mut deno_runtime: deno_core::JsRuntime,
888    ctx: CTX,
889    client: Arc<Client>,
890    media_type: PluginCodeType,
891    code: &str,
892    input: serde_json::Value,
893    timeout: Duration,
894) -> Result<Option<serde_json::Value>, AnyError> {
895    let isolate_handle = deno_runtime.v8_isolate().thread_safe_handle();
896
897    {
898        let terminate_handle = isolate_handle.clone();
899        deno_runtime.add_near_heap_limit_callback(move |current_limit, _initial_limit| {
900            // Forcefully stop the script and give V8 enough headroom to unwind
901            // cleanly instead of retriggering this callback in a tight loop.
902            terminate_handle.terminate_execution();
903            current_limit * 2
904        });
905    }
906
907    // Guards the whole execution below: if the script doesn't finish within
908    // `timeout`, this forcibly terminates it from another thread.
909    let _watchdog = ExecutionWatchdog::start(isolate_handle, timeout);
910
911    let js_runtime_state = Arc::new(Mutex::new(JSRuntimeState {
912        fhir_client: client,
913        ctx,
914        return_value: None,
915        input: Rc::new(input),
916    }));
917
918    {
919        let op_state = deno_runtime.op_state();
920        let mut op_state = op_state.borrow_mut();
921        op_state.put(js_runtime_state.clone());
922    }
923
924    let user_module_specifier = ModuleSpecifier::parse("memo://user.ts").unwrap();
925
926    let (_module_type, js_code) = cached_transpile_to_js(media_type, code)?;
927
928    let user_mod_id = deno_runtime
929        .load_side_es_module_from_code(&user_module_specifier, js_code)
930        .await?;
931
932    let main_mod_id = deno_runtime
933        .load_main_es_module_from_code(
934            &ModuleSpecifier::parse("memo://main.ts").unwrap(),
935            "import userFunction from 'memo://user.ts'; _internal_.setReturnValue(await userFunction(await _internal_.getInputValue()));"
936                .to_string(),
937        )
938        .await?;
939
940    // let mod_id = deno_runtime.load_main_es_module(&main_module).await?;
941    let user_module_load = deno_runtime.mod_evaluate(user_mod_id);
942    let main_module_load = deno_runtime.mod_evaluate(main_mod_id);
943
944    deno_runtime
945        .run_event_loop(PollEventLoopOptions::default())
946        .await?;
947
948    user_module_load.await?;
949    main_module_load.await?;
950    // Clean up the JSRuntimeState from the op state
951    // Allows unwrapping RC in next call to have owned_values.
952    {
953        let op_state = deno_runtime.op_state();
954        let mut op_state = op_state.borrow_mut();
955
956        op_state.take::<Arc<Mutex<JSRuntimeState<CTX, Client>>>>();
957    }
958
959    let owned_runetime_state = Arc::try_unwrap(js_runtime_state)
960        .map_err(|_| deno_error::JsErrorBox::type_error("Failed to unwrap JSRuntimeState"))?
961        .into_inner();
962
963    Ok(owned_runetime_state.return_value)
964}
965
966#[cfg(test)]
967pub(crate) mod tests {
968    use super::*;
969    use deno_core::serde_json::json;
970    use haste_fhir_client::request::{FHIRRequest, FHIRResponse};
971    use haste_fhir_client::url::ParsedParameters;
972    use haste_fhir_model::r4::generated::resources::{
973        Bundle, CapabilityStatement, Parameters, Resource,
974    };
975    use haste_fhir_model::r4::generated::terminology::IssueType;
976    use json_patch::Patch;
977    use std::time::Instant;
978
979    #[derive(Clone)]
980    pub(crate) struct TestCtx;
981
982    /// A `FHIRClient` double that isn't expected to be called by any of these
983    /// tests -- they only exercise the sandboxing behavior of `run_code`
984    /// itself, not the `fhir.read` op.
985    pub(crate) struct MockClient;
986
987    impl FHIRClient<TestCtx, OperationOutcomeError> for MockClient {
988        async fn request(
989            &self,
990            _ctx: TestCtx,
991            _request: FHIRRequest,
992        ) -> Result<FHIRResponse, OperationOutcomeError> {
993            unimplemented!("not used by these tests")
994        }
995
996        async fn capabilities(
997            &self,
998            _ctx: TestCtx,
999        ) -> Result<CapabilityStatement, OperationOutcomeError> {
1000            Ok(CapabilityStatement::default())
1001        }
1002
1003        async fn search_system(
1004            &self,
1005            _ctx: TestCtx,
1006            _parameters: ParsedParameters,
1007        ) -> Result<Bundle, OperationOutcomeError> {
1008            unimplemented!("not used by these tests")
1009        }
1010
1011        async fn search_type(
1012            &self,
1013            _ctx: TestCtx,
1014            _resource_type: ResourceType,
1015            _parameters: ParsedParameters,
1016        ) -> Result<Bundle, OperationOutcomeError> {
1017            Ok(Bundle::default())
1018        }
1019
1020        async fn create(
1021            &self,
1022            _ctx: TestCtx,
1023            _resource_type: ResourceType,
1024            resource: Resource,
1025        ) -> Result<Resource, OperationOutcomeError> {
1026            Ok(resource)
1027        }
1028
1029        async fn update(
1030            &self,
1031            _ctx: TestCtx,
1032            _resource_type: ResourceType,
1033            _id: String,
1034            _resource: Resource,
1035        ) -> Result<Resource, OperationOutcomeError> {
1036            unimplemented!("not used by these tests")
1037        }
1038
1039        async fn conditional_update(
1040            &self,
1041            _ctx: TestCtx,
1042            _resource_type: ResourceType,
1043            _parameters: ParsedParameters,
1044            _resource: Resource,
1045        ) -> Result<Resource, OperationOutcomeError> {
1046            unimplemented!("not used by these tests")
1047        }
1048
1049        async fn patch(
1050            &self,
1051            _ctx: TestCtx,
1052            _resource_type: ResourceType,
1053            _id: String,
1054            _patches: Patch,
1055        ) -> Result<Resource, OperationOutcomeError> {
1056            unimplemented!("not used by these tests")
1057        }
1058
1059        async fn read(
1060            &self,
1061            _ctx: TestCtx,
1062            _resource_type: ResourceType,
1063            _id: String,
1064        ) -> Result<Option<Resource>, OperationOutcomeError> {
1065            Ok(None)
1066        }
1067
1068        async fn vread(
1069            &self,
1070            _ctx: TestCtx,
1071            _resource_type: ResourceType,
1072            _id: String,
1073            _version_id: String,
1074        ) -> Result<Option<Resource>, OperationOutcomeError> {
1075            unimplemented!("not used by these tests")
1076        }
1077
1078        async fn delete_instance(
1079            &self,
1080            _ctx: TestCtx,
1081            _resource_type: ResourceType,
1082            _id: String,
1083        ) -> Result<(), OperationOutcomeError> {
1084            Ok(())
1085        }
1086
1087        async fn delete_type(
1088            &self,
1089            _ctx: TestCtx,
1090            _resource_type: ResourceType,
1091            _parameters: ParsedParameters,
1092        ) -> Result<(), OperationOutcomeError> {
1093            unimplemented!("not used by these tests")
1094        }
1095
1096        async fn delete_system(
1097            &self,
1098            _ctx: TestCtx,
1099            _parameters: ParsedParameters,
1100        ) -> Result<(), OperationOutcomeError> {
1101            unimplemented!("not used by these tests")
1102        }
1103
1104        async fn history_system(
1105            &self,
1106            _ctx: TestCtx,
1107            _parameters: ParsedParameters,
1108        ) -> Result<Bundle, OperationOutcomeError> {
1109            unimplemented!("not used by these tests")
1110        }
1111
1112        async fn history_type(
1113            &self,
1114            _ctx: TestCtx,
1115            _resource_type: ResourceType,
1116            _parameters: ParsedParameters,
1117        ) -> Result<Bundle, OperationOutcomeError> {
1118            unimplemented!("not used by these tests")
1119        }
1120
1121        async fn history_instance(
1122            &self,
1123            _ctx: TestCtx,
1124            _resource_type: ResourceType,
1125            _id: String,
1126            _parameters: ParsedParameters,
1127        ) -> Result<Bundle, OperationOutcomeError> {
1128            unimplemented!("not used by these tests")
1129        }
1130
1131        async fn invoke_instance(
1132            &self,
1133            _ctx: TestCtx,
1134            _resource_type: ResourceType,
1135            _id: String,
1136            _operation: String,
1137            _parameters: Parameters,
1138        ) -> Result<Resource, OperationOutcomeError> {
1139            unimplemented!("not used by these tests")
1140        }
1141
1142        async fn invoke_type(
1143            &self,
1144            _ctx: TestCtx,
1145            _resource_type: ResourceType,
1146            _operation: String,
1147            _parameters: Parameters,
1148        ) -> Result<Resource, OperationOutcomeError> {
1149            unimplemented!("not used by these tests")
1150        }
1151
1152        async fn invoke_system(
1153            &self,
1154            _ctx: TestCtx,
1155            operation: String,
1156            _parameters: Parameters,
1157        ) -> Result<Resource, OperationOutcomeError> {
1158            serde_json::from_value(json!({ "resourceType": "Parameters", "id": operation }))
1159                .map_err(|error| {
1160                    OperationOutcomeError::error(IssueType::invalid(), error.to_string())
1161                })
1162        }
1163
1164        async fn transaction(
1165            &self,
1166            _ctx: TestCtx,
1167            _bundle: Bundle,
1168        ) -> Result<Bundle, OperationOutcomeError> {
1169            unimplemented!("not used by these tests")
1170        }
1171
1172        async fn batch(
1173            &self,
1174            _ctx: TestCtx,
1175            _bundle: Bundle,
1176        ) -> Result<Bundle, OperationOutcomeError> {
1177            unimplemented!("not used by these tests")
1178        }
1179    }
1180
1181    #[tokio::test]
1182    async fn happy_path_javascript_returns_value() {
1183        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1184        let result = run_code(
1185            deno_runtime,
1186            TestCtx,
1187            Arc::new(MockClient),
1188            PluginCodeType::JavaScript,
1189            "export default async function () { return { answer: 42 }; }",
1190            json!({}),
1191            Duration::from_secs(5),
1192        )
1193        .await
1194        .expect("script should run successfully");
1195
1196        assert_eq!(result, Some(json!({ "answer": 42 })));
1197    }
1198
1199    #[tokio::test]
1200    async fn happy_path_typescript_returns_value() {
1201        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1202        let result = run_code(
1203            deno_runtime,
1204            TestCtx,
1205            Arc::new(MockClient),
1206            PluginCodeType::TypeScript,
1207            "export default async function (): Promise<{ answer: number }> { return { answer: 42 }; }",
1208            json!({}),
1209            Duration::from_secs(5),
1210        )
1211        .await
1212        .expect("script should run successfully");
1213
1214        assert_eq!(result, Some(json!({ "answer": 42 })));
1215    }
1216
1217    #[tokio::test]
1218    async fn fhir_create_op_round_trips_through_the_client() {
1219        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1220        let result = run_code(
1221            deno_runtime,
1222            TestCtx,
1223            Arc::new(MockClient),
1224            PluginCodeType::JavaScript,
1225            "export default async function () { return await fhir.create('Patient', { resourceType: 'Patient', id: 'example' }); }",
1226            json!({}),
1227            Duration::from_secs(5),
1228        )
1229        .await
1230        .expect("script should call fhir.create");
1231
1232        assert_eq!(
1233            result,
1234            Some(json!({ "resourceType": "Patient", "id": "example" }))
1235        );
1236    }
1237
1238    #[tokio::test]
1239    async fn fhir_search_type_op_returns_a_bundle() {
1240        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1241        let result = run_code(
1242            deno_runtime,
1243            TestCtx,
1244            Arc::new(MockClient),
1245            PluginCodeType::JavaScript,
1246            "export default async function () { return await fhir.searchType('Patient', { name: 'Smith', _count: 10 }); }",
1247            json!({}),
1248            Duration::from_secs(5),
1249        )
1250        .await
1251        .expect("script should call fhir.searchType");
1252
1253        assert_eq!(result, Some(json!({ "resourceType": "Bundle" })));
1254    }
1255
1256    #[tokio::test]
1257    async fn fhir_delete_instance_op_resolves() {
1258        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1259        let result = run_code(
1260            deno_runtime,
1261            TestCtx,
1262            Arc::new(MockClient),
1263            PluginCodeType::JavaScript,
1264            "export default async function () { await fhir.deleteInstance('Patient', 'example'); return { deleted: true }; }",
1265            json!({}),
1266            Duration::from_secs(5),
1267        )
1268        .await
1269        .expect("script should call fhir.deleteInstance");
1270
1271        assert_eq!(result, Some(json!({ "deleted": true })));
1272    }
1273
1274    #[tokio::test]
1275    async fn fhir_invoke_system_op_defaults_missing_parameters() {
1276        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1277        let result = run_code(
1278            deno_runtime,
1279            TestCtx,
1280            Arc::new(MockClient),
1281            PluginCodeType::JavaScript,
1282            "export default async function () { return await fhir.invokeSystem('everything'); }",
1283            json!({}),
1284            Duration::from_secs(5),
1285        )
1286        .await
1287        .expect("script should call fhir.invokeSystem");
1288
1289        assert_eq!(
1290            result,
1291            Some(json!({ "resourceType": "Parameters", "id": "everything" }))
1292        );
1293    }
1294
1295    #[tokio::test]
1296    async fn fhir_capabilities_op_returns_the_capability_statement() {
1297        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1298        let result = run_code(
1299            deno_runtime,
1300            TestCtx,
1301            Arc::new(MockClient),
1302            PluginCodeType::JavaScript,
1303            "export default async function () { return await fhir.capabilities(); }",
1304            json!({}),
1305            Duration::from_secs(5),
1306        )
1307        .await
1308        .expect("script should call fhir.capabilities");
1309
1310        assert_eq!(
1311            result,
1312            Some(json!({ "resourceType": "CapabilityStatement" }))
1313        );
1314    }
1315
1316    #[tokio::test]
1317    async fn imports_are_rejected() {
1318        // Regression test: this loader used to read arbitrary files off the
1319        // host filesystem for any `import` a tenant's script happened to
1320        // contain. It must now be rejected outright.
1321        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1322        let result = run_code(
1323            deno_runtime,
1324            TestCtx,
1325            Arc::new(MockClient),
1326            PluginCodeType::JavaScript,
1327            "import secret from 'file:///etc/passwd'; export default async function () { return secret; }",
1328            json!({}),
1329            Duration::from_secs(5),
1330        )
1331        .await;
1332
1333        assert!(
1334            result.is_err(),
1335            "importing an arbitrary file must never succeed"
1336        );
1337    }
1338
1339    #[tokio::test]
1340    async fn runaway_script_is_terminated_within_timeout() {
1341        let timeout = Duration::from_millis(200);
1342        let started = Instant::now();
1343
1344        let deno_runtime = build_deno_runtime::<TestCtx, MockClient>();
1345        let result = run_code(
1346            deno_runtime,
1347            TestCtx,
1348            Arc::new(MockClient),
1349            PluginCodeType::JavaScript,
1350            "export default async function () { while (true) {} }",
1351            json!({}),
1352            timeout,
1353        )
1354        .await;
1355
1356        assert!(result.is_err(), "runaway script must be terminated");
1357        assert!(
1358            started.elapsed() < timeout * 5,
1359            "termination took far longer than the configured timeout: {:?}",
1360            started.elapsed()
1361        );
1362    }
1363}