Skip to content

Commit bcd0328

Browse files
committed
feat: add user module registration for reusable ES modules
Add add_module/remove_module/clear_modules API to JSSandbox, allowing handlers (and other modules) to import reusable ES modules using the 'namespace:name' convention (default namespace: 'user'). Guest runtime: - Add UserModuleLoader (Resolver + Loader) for lazy compilation - Add register_module guest function - Modules compiled on first import, avoiding ordering issues Host library (hyperlight-js): - Add modules HashMap to JSSandbox with full CRUD API - add_module / add_module_ns / remove_module / remove_module_ns / clear_modules - Modules registered before handlers in get_loaded_sandbox() - Export DEFAULT_MODULE_NAMESPACE constant - Namespace validation: 'host' reserved, no colons allowed NAPI wrapper (js-host-api): - Add addModule / removeModule / clearModules on JSSandboxWrapper - Error enrichment wrapping for new sync methods Import capabilities: - User modules can import other user modules - User modules can import built-in modules (crypto, console, etc.) - User modules can import host function modules (host:X) - Missing modules fail at getLoadedSandbox() with clear errors
1 parent 79118a8 commit bcd0328

File tree

14 files changed

+2590
-14
lines changed

14 files changed

+2590
-14
lines changed

Justfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ run-js-host-api-examples target=default-target features="": (build-js-host-api t
175175
@echo ""
176176
cd src/js-host-api && node examples/host-functions.js
177177
@echo ""
178+
cd src/js-host-api && node examples/user-modules.js
179+
@echo ""
178180
@echo "✅ All examples completed successfully!"
179181

180182
test-all target=default-target features="": (test target features) (test-monitors target) (test-js-host-api target features)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Provides a capability to run JavaScript inside of Hyperlight using quickjs as th
88
- [Observability](docs/observability.md) - Metrics and tracing
99
- [Crashdumps](docs/create-and-analyse-guest-crashdumps.md) - Creating and analyzing guest crash dumps
1010
- [Debugging the guest runtime](docs/guest-runtime-debugging.md) - Debugging the guest runtime using GDB or LLDB
11-
- [JS Host API](src/js-host-api/README.md) - Node.js bindings
11+
- [JS Host API](src/js-host-api/README.md) - Node.js bindings (includes user modules and host functions)
1212

1313
## Build prerequisites
1414

src/hyperlight-js-runtime/src/lib.rs

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub(crate) mod utils;
2727
use alloc::format;
2828
use alloc::rc::Rc;
2929
use alloc::string::{String, ToString};
30+
use core::cell::RefCell;
3031

3132
use anyhow::{anyhow, Context as _};
3233
use hashbrown::HashMap;
@@ -48,22 +49,68 @@ struct Handler<'a> {
4849
func: Persistent<Function<'a>>,
4950
}
5051

52+
/// A module loader for user-registered modules.
53+
///
54+
/// Stores module source code keyed by qualified name (e.g. `user:utils`).
55+
/// Modules are compiled lazily when first imported — this avoids ordering
56+
/// issues between modules that depend on each other.
57+
///
58+
/// Implements both [`Resolver`] and [`Loader`] so it can be inserted into
59+
/// the rquickjs module loader chain alongside the host and native loaders.
60+
#[derive(Default, Clone)]
61+
struct UserModuleLoader {
62+
modules: Rc<RefCell<HashMap<String, String>>>,
63+
}
64+
65+
impl Resolver for UserModuleLoader {
66+
fn resolve(&mut self, _ctx: &Ctx<'_>, base: &str, name: &str) -> Result<String> {
67+
if self.modules.borrow().contains_key(name) {
68+
Ok(name.to_string())
69+
} else {
70+
Err(rquickjs::Error::new_resolving(base, name))
71+
}
72+
}
73+
}
74+
75+
impl Loader for UserModuleLoader {
76+
fn load<'js>(&mut self, ctx: &Ctx<'js>, name: &str) -> Result<Module<'js>> {
77+
let source = self
78+
.modules
79+
.borrow()
80+
.get(name)
81+
.cloned()
82+
.ok_or_else(|| rquickjs::Error::new_loading(name))?;
83+
Module::declare(ctx.clone(), name, source)
84+
}
85+
}
86+
5187
/// This is the main entry point for the library.
5288
/// It manages the QuickJS runtime, as well as the registered handlers and host modules.
5389
pub struct JsRuntime {
5490
context: Context,
5591
handlers: HashMap<String, Handler<'static>>,
92+
/// Lazily-loaded user modules, keyed by qualified name (e.g. `user:utils`).
93+
user_modules: UserModuleLoader,
5694
}
5795

5896
// SAFETY:
5997
// 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.
98+
// is because `rquickjs::Context` is not `Send` (it holds a raw pointer) and
99+
// `UserModuleLoader` contains `Rc<RefCell<HashMap>>` which is `!Send`.
100+
//
61101
// Raw pointers in rust are not marked as `Send` as lint rather than an actual
62102
// safety concern (see https://doc.rust-lang.org/nomicon/send-and-sync.html).
63103
// Moreover, rquickjs DOES implement Send for Context when the "parallel" feature
64104
// 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.
105+
//
106+
// The `Rc<RefCell<>>` in `UserModuleLoader` is shared with the rquickjs loader
107+
// chain (cloned during `set_loader`). This is safe because:
108+
// 1. Every public method of `JsRuntime` takes `&mut self`, ensuring exclusive access.
109+
// 2. The guest runtime is single-threaded (`#![no_std]` micro-VM).
110+
// 3. The `Rc` clone only creates shared ownership within the same thread.
111+
//
112+
// If the runtime ever becomes multi-threaded, `Rc<RefCell<>>` would need to be
113+
// replaced with `Arc<Mutex<>>` or similar.
67114
unsafe impl Send for JsRuntime {}
68115

69116
impl JsRuntime {
@@ -78,10 +125,25 @@ impl JsRuntime {
78125
// We need to do this before setting up the globals as many of the globals are implemented
79126
// as native modules, and so they need the module loader to be able to be loaded.
80127
let host_loader = HostModuleLoader::default();
128+
let user_modules = UserModuleLoader::default();
81129
let native_loader = NativeModuleLoader;
82130
let module_loader = ModuleLoader::new(host);
83131

84-
let loader = (host_loader.clone(), native_loader, module_loader);
132+
// User modules are second in the chain — after host modules but before
133+
// native and filesystem loaders — so `user:X` is resolved before falling
134+
// through to built-in or file-based resolution.
135+
//
136+
// NOTE: This means a user module with a qualified name matching a native
137+
// module (e.g. `"crypto"`) would shadow the built-in. In practice this
138+
// cannot happen accidentally because the host layer enforces the
139+
// `namespace:name` format (e.g. `"user:crypto"`), which never collides
140+
// with unqualified native module names.
141+
let loader = (
142+
host_loader.clone(),
143+
user_modules.clone(),
144+
native_loader,
145+
module_loader,
146+
);
85147
runtime.set_loader(loader.clone(), loader);
86148

87149
context.with(|ctx| -> anyhow::Result<()> {
@@ -96,6 +158,7 @@ impl JsRuntime {
96158
Ok(Self {
97159
context,
98160
handlers: HashMap::new(),
161+
user_modules,
99162
})
100163
}
101164

@@ -190,6 +253,37 @@ impl JsRuntime {
190253
Ok(())
191254
}
192255

256+
/// Register a user module with the runtime.
257+
///
258+
/// The module source is stored for lazy compilation — it will be compiled
259+
/// and evaluated by QuickJS the first time it is imported by a handler or
260+
/// another user module. This avoids ordering issues between interdependent
261+
/// modules.
262+
///
263+
/// The `module_name` should be the fully qualified name (e.g. `user:utils`)
264+
/// that guest JavaScript will use in `import` statements.
265+
///
266+
/// # Validation
267+
///
268+
/// The name must not be empty. Primary validation (colons, reserved
269+
/// namespaces, duplicates) is enforced by the host-side `JSSandbox` layer;
270+
/// this check provides defense-in-depth at the guest boundary.
271+
pub fn register_module(
272+
&mut self,
273+
module_name: impl Into<String>,
274+
module_source: impl Into<String>,
275+
) -> anyhow::Result<()> {
276+
let module_name = module_name.into();
277+
if module_name.is_empty() {
278+
anyhow::bail!("Module name must not be empty");
279+
}
280+
self.user_modules
281+
.modules
282+
.borrow_mut()
283+
.insert(module_name, module_source.into());
284+
Ok(())
285+
}
286+
193287
/// Run a registered handler function with the given event data.
194288
/// 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.
195289
/// The result is returned as a JSON string.

src/hyperlight-js-runtime/src/main/hyperlight.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ fn register_handler(
9292
Ok(())
9393
}
9494

95+
#[guest_function("register_module")]
96+
#[instrument(skip_all, level = "info")]
97+
fn register_module(module_name: String, module_source: String) -> Result<()> {
98+
RUNTIME
99+
.lock()
100+
.register_module(module_name, module_source)?;
101+
Ok(())
102+
}
103+
95104
#[host_function("CallHostJsFunction")]
96105
fn call_host_js_function(module_name: String, func_name: String, args: String) -> Result<String>;
97106

src/hyperlight-js/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ name = "runtime_debugging"
111111
path = "examples/runtime_debugging/main.rs"
112112
test = false
113113

114+
[[example]]
115+
name = "user_modules"
116+
path = "examples/user_modules/main.rs"
117+
test = false
118+
114119
[[bench]]
115120
name = "benchmarks"
116121
harness = false

0 commit comments

Comments
 (0)