Skip to main content

hyperlight_js_runtime/
lib.rs

1/*
2Copyright 2026  The Hyperlight Authors.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8    http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16#![no_std]
17#![no_main]
18extern crate alloc;
19
20mod globals;
21pub mod host;
22mod host_fn;
23mod libc;
24mod modules;
25pub(crate) mod utils;
26
27use alloc::format;
28use alloc::rc::Rc;
29use alloc::string::{String, ToString};
30
31use anyhow::{anyhow, Context as _};
32use hashbrown::HashMap;
33use rquickjs::loader::{Loader, Resolver};
34use rquickjs::promise::MaybePromise;
35use rquickjs::{Context, Ctx, Function, Module, Persistent, Result, Runtime, Value};
36use serde::de::DeserializeOwned;
37use serde::Serialize;
38use tracing::instrument;
39
40use crate::host::Host;
41use crate::host_fn::{HostFunction, HostModuleLoader};
42use crate::modules::NativeModuleLoader;
43
44/// A handler is a javascript function that takes a single `event` object parameter,
45/// and is registered to the static `Context` instance
46#[derive(Clone)]
47struct Handler<'a> {
48    func: Persistent<Function<'a>>,
49}
50
51/// This is the main entry point for the library.
52/// It manages the QuickJS runtime, as well as the registered handlers and host modules.
53pub struct JsRuntime {
54    context: Context,
55    handlers: HashMap<String, Handler<'static>>,
56}
57
58// SAFETY:
59// This is safe. The reason it is not automatically implemented by the compiler
60// is because `rquickjs::Context` is not `Send` because it holds a raw pointer.
61// Raw pointers in rust are not marked as `Send` as lint rather than an actual
62// safety concern (see https://doc.rust-lang.org/nomicon/send-and-sync.html).
63// Moreover, rquickjs DOES implement Send for Context when the "parallel" feature
64// is enabled, further indicating that it is safe for this to implement `Send`.
65// Moreover, every public method of `JsRuntime` takes `&mut self`, and so we can
66// be certain that there are no concurrent accesses to it.
67unsafe impl Send for JsRuntime {}
68
69impl JsRuntime {
70    /// Create a new `JsRuntime` with the given host.
71    /// The resulting runtime will have global objects registered.
72    #[instrument(skip_all, level = "info")]
73    pub fn new<H: Host + 'static>(host: H) -> anyhow::Result<Self> {
74        let runtime = Runtime::new().context("Unable to initialize JS_RUNTIME")?;
75        let context = Context::full(&runtime).context("Unable to create JS context")?;
76
77        // Setup the module loader.
78        // We need to do this before setting up the globals as many of the globals are implemented
79        // as native modules, and so they need the module loader to be able to be loaded.
80        let host_loader = HostModuleLoader::default();
81        let native_loader = NativeModuleLoader;
82        let module_loader = ModuleLoader::new(host);
83
84        let loader = (host_loader.clone(), native_loader, module_loader);
85        runtime.set_loader(loader.clone(), loader);
86
87        context.with(|ctx| -> anyhow::Result<()> {
88            // we need to install the host loader in the context as the loader uses the context to
89            // store some global state needed for module instantiation.
90            host_loader.install(&ctx)?;
91
92            // Setup the global objects in the context, so they are available to the handler scripts.
93            globals::setup(&ctx).catch(&ctx)
94        })?;
95
96        Ok(Self {
97            context,
98            handlers: HashMap::new(),
99        })
100    }
101
102    /// Register a host function in the specified module.
103    /// The function takes and returns a JSON string, which is deserialized and serialized by the runtime.
104    /// The arguments are serialized as a JSON array containing all the arguments passed to the function.
105    pub fn register_json_host_function(
106        &mut self,
107        module_name: impl Into<String>,
108        function_name: impl Into<String>,
109        function: impl Fn(String) -> anyhow::Result<String> + 'static,
110    ) -> anyhow::Result<()> {
111        self.context.with(|ctx| {
112            ctx.userdata::<HostModuleLoader>()
113                .context("HostModuleLoader not found in context")?
114                .borrow_mut()
115                .entry(module_name.into())
116                .or_default()
117                .add_function(function_name.into(), HostFunction::new_json(function));
118            Ok(())
119        })
120    }
121
122    /// Register a host function in the specified module.
123    /// The function takes and returns any type that can be (de)serialized by `serde`.
124    pub fn register_host_function<Args, Output>(
125        &mut self,
126        module_name: impl Into<String>,
127        function_name: impl Into<String>,
128        function: impl fn_traits::Fn<Args, Output = anyhow::Result<Output>> + 'static,
129    ) -> anyhow::Result<()>
130    where
131        Args: DeserializeOwned,
132        Output: Serialize,
133    {
134        self.context.with(|ctx| {
135            ctx.userdata::<HostModuleLoader>()
136                .context("HostModuleLoader not found in context")?
137                .borrow_mut()
138                .entry(module_name.into())
139                .or_default()
140                .add_function(function_name.into(), HostFunction::new_serde(function));
141            Ok(())
142        })
143    }
144
145    /// Register a handler function with the runtime.
146    /// The handler script is a JavaScript module that exports a function named `handler`.
147    /// The handler function takes a single argument, which is the event data deserialized from a JSON string.
148    pub fn register_handler(
149        &mut self,
150        function_name: impl Into<String>,
151        handler_script: impl Into<String>,
152        handler_pwd: impl Into<String>,
153    ) -> anyhow::Result<()> {
154        let function_name = function_name.into();
155        let handler_script = handler_script.into();
156        let handler_pwd = handler_pwd.into();
157
158        // If the handler script doesn't already contain an ES export statement,
159        // append one for the user. This is a convenience for the common case where
160        // the handler script defines a handler function without explicitly exporting it.
161        //
162        // We check whether any line *starts* with `export` (after leading whitespace)
163        // rather than using a naive `.contains("export")`, which would false-positive
164        // on string literals (e.g. '<config mode="export">'), comments
165        // (e.g. // TODO: export data), or identifiers (e.g. exportPath).
166        let handler_script = if !has_export_statement(&handler_script) {
167            format!("{}\nexport {{ handler }};", handler_script)
168        } else {
169            handler_script
170        };
171
172        // We create a "virtual" path for the handler module based on the function name and the provided handler directory.
173        let handler_path = make_handler_path(&function_name, &handler_pwd);
174
175        let func = self.context.with(|ctx| -> anyhow::Result<_> {
176            // Declare the module for the handler script, and evaluate it to get the exported handler function.
177            let module =
178                Module::declare(ctx.clone(), handler_path.as_str(), handler_script.clone())
179                    .catch(&ctx)?;
180
181            let (module, promise) = module.eval().catch(&ctx)?;
182
183            promise.finish::<()>().catch(&ctx)?;
184
185            // Get the exported handler function from the module namespace
186            let handler_func: Function = module.get("handler").catch(&ctx)?;
187
188            // Save the handler function as a Persistent so it can be returned outside of the `enter` closure.
189            Ok(Persistent::save(&ctx, handler_func))
190        })?;
191
192        // Store the handler function in the `handlers` map, so it can be called later when the handler is triggered.
193        self.handlers.insert(function_name, Handler { func });
194
195        Ok(())
196    }
197
198    /// Run a registered handler function with the given event data.
199    /// The event data is passed as a JSON string, and the handler function is expected to return a value that can be serialized to JSON.
200    /// The result is returned as a JSON string.
201    /// If `run_gc` is true, the runtime will run a garbage collection cycle after running the handler.
202    pub fn run_handler(
203        &mut self,
204        function_name: String,
205        event: String,
206        run_gc: bool,
207    ) -> anyhow::Result<String> {
208        // Get the handler function from the `handlers` map. If there is no handler registered for the given function name, return an error.
209        let handler = self
210            .handlers
211            .get(&function_name)
212            .with_context(|| format!("No handler registered for function {function_name}"))?
213            .clone();
214
215        // Create a guard that will flush any output when dropped (i.e., after running the handler).
216        // This makes sure that any output generated through libc is flushed out of the libc's stdout buffer.
217        let _guard = FlushGuard;
218
219        // Evaluate `handler(event)`, and get resulting object as String
220        self.context.with(|ctx| {
221            // Create a guard that will run a GC cycle when dropped if `run_gc` is true.
222            let _gc_guard = MaybeRunGcGuard::new(run_gc, &ctx);
223
224            // Restore the handler function from the Persistent reference.
225            let func = handler.func.clone().restore(&ctx).catch(&ctx)?;
226
227            // Call it with the event data parsed as a JSON value.
228            let arg = ctx.json_parse(event).catch(&ctx)?;
229
230            // If the handler returned a promise that resolves immediately, we resolve it.
231            let promise: MaybePromise = func.call((arg,)).catch(&ctx)?;
232            let obj: Value = promise.finish().catch(&ctx)?;
233
234            // Serialize the result to a JSON string and return it.
235            ctx.json_stringify(obj)
236                .catch(&ctx)?
237                .context("The handler function did not return a value")?
238                .to_string()
239                .catch(&ctx)
240        })
241    }
242}
243
244impl Drop for JsRuntime {
245    fn drop(&mut self) {
246        // make sure we flush any output when dropping the runtime
247        modules::io::io::flush();
248        // clear handlers to drop Persistent references before Context is dropped
249        // otherwise the runtime will abort on drop due to the memory leak.
250        self.handlers.clear();
251    }
252}
253
254// A module loader that calls out to the host to resolve and load modules
255#[derive(Clone)]
256struct ModuleLoader {
257    host: Rc<dyn Host>,
258}
259
260impl ModuleLoader {
261    fn new(host: impl Host + 'static) -> Self {
262        Self {
263            host: Rc::new(host),
264        }
265    }
266}
267
268impl Resolver for ModuleLoader {
269    fn resolve(&mut self, _ctx: &Ctx<'_>, base: &str, name: &str) -> Result<String> {
270        // quickjs uses the module path as the base for relative imports
271        // but oxc_resolver expects the directory as the base
272        let (dir, _) = base.rsplit_once('/').unwrap_or((".", ""));
273
274        let path = self
275            .host
276            .resolve_module(dir.to_string(), name.to_string())
277            .map_err(|_err| rquickjs::Error::new_resolving(base, name))?;
278
279        // convert backslashes to forward slashes for windows compatibility
280        let path = path.replace('\\', "/");
281        Ok(path)
282    }
283}
284
285impl Loader for ModuleLoader {
286    fn load<'js>(&mut self, ctx: &Ctx<'js>, name: &str) -> Result<Module<'js>> {
287        let source = self
288            .host
289            .load_module(name.to_string())
290            .map_err(|_err| rquickjs::Error::new_loading(name))?;
291
292        Module::declare(ctx.clone(), name, source)
293    }
294}
295
296fn make_handler_path(function_name: &str, handler_dir: &str) -> String {
297    let handler_dir = if handler_dir.is_empty() {
298        "."
299    } else {
300        handler_dir
301    };
302
303    let function_name = if function_name.is_empty() {
304        "handler"
305    } else {
306        function_name
307    };
308
309    let function_name = function_name.replace('\\', "/");
310    let mut handler_path = handler_dir.replace('\\', "/");
311    if !handler_path.ends_with('/') {
312        handler_path.push('/');
313    }
314    handler_path.push_str(&function_name);
315
316    if !handler_path.ends_with(".js") && !handler_path.ends_with(".mjs") {
317        handler_path.push_str(".js");
318    }
319
320    handler_path
321}
322
323/// Returns `true` if the script contains an actual ES `export` statement
324/// (as opposed to the word "export" inside a string literal, comment, or
325/// identifier like `exportPath`).
326///
327/// The heuristic checks whether any source line begins with `export` (after
328/// optional leading whitespace). This avoids the false positives from a
329/// naive `.contains("export")` while staying `no_std`-compatible.
330fn has_export_statement(script: &str) -> bool {
331    script.lines().any(|line| {
332        let trimmed = line.trim_start();
333        trimmed.starts_with("export ") || trimmed.starts_with("export{")
334    })
335}
336
337// RAII guard that flushes the output buffer of libc when dropped.
338// This is used to make sure we flush all output after running a handler, without needing to manually call it in every code path.
339struct FlushGuard;
340
341impl Drop for FlushGuard {
342    fn drop(&mut self) {
343        modules::io::io::flush();
344    }
345}
346
347trait CatchJsErrorExt {
348    type Ok;
349    fn catch(self, ctx: &Ctx<'_>) -> anyhow::Result<Self::Ok>;
350}
351
352impl<T> CatchJsErrorExt for rquickjs::Result<T> {
353    type Ok = T;
354    fn catch(self, ctx: &Ctx<'_>) -> anyhow::Result<T> {
355        match rquickjs::CatchResultExt::catch(self, ctx) {
356            Ok(s) => Ok(s),
357            Err(e) => Err(anyhow!("Runtime error: {e:#?}")),
358        }
359    }
360}
361
362// RAII guard that runs a GC cycle when dropped if `run_gc` is true.
363// This is used to make sure we run a GC cycle after running a handler if requested, without needing to manually call it in every code path.
364struct MaybeRunGcGuard<'a> {
365    run_gc: bool,
366    ctx: Ctx<'a>,
367}
368
369impl<'a> MaybeRunGcGuard<'a> {
370    fn new(run_gc: bool, ctx: &Ctx<'a>) -> Self {
371        Self {
372            run_gc,
373            ctx: ctx.clone(),
374        }
375    }
376}
377
378impl Drop for MaybeRunGcGuard<'_> {
379    fn drop(&mut self) {
380        if self.run_gc {
381            // safety: we are in the same context
382            self.ctx.run_gc();
383        }
384    }
385}