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}