diff --git a/Justfile b/Justfile index 95c9ac9..e66a01d 100644 --- a/Justfile +++ b/Justfile @@ -175,6 +175,8 @@ run-js-host-api-examples target=default-target features="": (build-js-host-api t @echo "" cd src/js-host-api && node examples/host-functions.js @echo "" + cd src/js-host-api && node examples/user-modules.js + @echo "" @echo "✅ All examples completed successfully!" test-all target=default-target features="": (test target features) (test-monitors target) (test-js-host-api target features) diff --git a/README.md b/README.md index 5f17a0f..621539f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Provides a capability to run JavaScript inside of Hyperlight using quickjs as th - [Observability](docs/observability.md) - Metrics and tracing - [Crashdumps](docs/create-and-analyse-guest-crashdumps.md) - Creating and analyzing guest crash dumps - [Debugging the guest runtime](docs/guest-runtime-debugging.md) - Debugging the guest runtime using GDB or LLDB -- [JS Host API](src/js-host-api/README.md) - Node.js bindings +- [JS Host API](src/js-host-api/README.md) - Node.js bindings (includes user modules and host functions) ## Build prerequisites diff --git a/src/hyperlight-js-runtime/src/lib.rs b/src/hyperlight-js-runtime/src/lib.rs index 4d89e2e..3bb670b 100644 --- a/src/hyperlight-js-runtime/src/lib.rs +++ b/src/hyperlight-js-runtime/src/lib.rs @@ -27,6 +27,7 @@ pub(crate) mod utils; use alloc::format; use alloc::rc::Rc; use alloc::string::{String, ToString}; +use core::cell::RefCell; use anyhow::{anyhow, Context as _}; use hashbrown::HashMap; @@ -48,22 +49,68 @@ struct Handler<'a> { func: Persistent>, } +/// A module loader for user-registered modules. +/// +/// Stores module source code keyed by qualified name (e.g. `user:utils`). +/// Modules are compiled lazily when first imported — this avoids ordering +/// issues between modules that depend on each other. +/// +/// Implements both [`Resolver`] and [`Loader`] so it can be inserted into +/// the rquickjs module loader chain alongside the host and native loaders. +#[derive(Default, Clone)] +struct UserModuleLoader { + modules: Rc>>, +} + +impl Resolver for UserModuleLoader { + fn resolve(&mut self, _ctx: &Ctx<'_>, base: &str, name: &str) -> Result { + if self.modules.borrow().contains_key(name) { + Ok(name.to_string()) + } else { + Err(rquickjs::Error::new_resolving(base, name)) + } + } +} + +impl Loader for UserModuleLoader { + fn load<'js>(&mut self, ctx: &Ctx<'js>, name: &str) -> Result> { + let source = self + .modules + .borrow() + .get(name) + .cloned() + .ok_or_else(|| rquickjs::Error::new_loading(name))?; + Module::declare(ctx.clone(), name, source) + } +} + /// This is the main entry point for the library. /// It manages the QuickJS runtime, as well as the registered handlers and host modules. pub struct JsRuntime { context: Context, handlers: HashMap>, + /// Lazily-loaded user modules, keyed by qualified name (e.g. `user:utils`). + user_modules: UserModuleLoader, } // SAFETY: // This is safe. The reason it is not automatically implemented by the compiler -// is because `rquickjs::Context` is not `Send` because it holds a raw pointer. +// is because `rquickjs::Context` is not `Send` (it holds a raw pointer) and +// `UserModuleLoader` contains `Rc>` which is `!Send`. +// // Raw pointers in rust are not marked as `Send` as lint rather than an actual // safety concern (see https://doc.rust-lang.org/nomicon/send-and-sync.html). // Moreover, rquickjs DOES implement Send for Context when the "parallel" feature // is enabled, further indicating that it is safe for this to implement `Send`. -// Moreover, every public method of `JsRuntime` takes `&mut self`, and so we can -// be certain that there are no concurrent accesses to it. +// +// The `Rc>` in `UserModuleLoader` is shared with the rquickjs loader +// chain (cloned during `set_loader`). This is safe because: +// 1. Every public method of `JsRuntime` takes `&mut self`, ensuring exclusive access. +// 2. The guest runtime is single-threaded (`#![no_std]` micro-VM). +// 3. The `Rc` clone only creates shared ownership within the same thread. +// +// If the runtime ever becomes multi-threaded, `Rc>` would need to be +// replaced with `Arc>` or similar. unsafe impl Send for JsRuntime {} impl JsRuntime { @@ -78,10 +125,25 @@ impl JsRuntime { // We need to do this before setting up the globals as many of the globals are implemented // as native modules, and so they need the module loader to be able to be loaded. let host_loader = HostModuleLoader::default(); + let user_modules = UserModuleLoader::default(); let native_loader = NativeModuleLoader; let module_loader = ModuleLoader::new(host); - let loader = (host_loader.clone(), native_loader, module_loader); + // User modules are second in the chain — after host modules but before + // native and filesystem loaders — so `user:X` is resolved before falling + // through to built-in or file-based resolution. + // + // NOTE: This means a user module with a qualified name matching a native + // module (e.g. `"crypto"`) would shadow the built-in. In practice this + // cannot happen accidentally because the host layer enforces the + // `namespace:name` format (e.g. `"user:crypto"`), which never collides + // with unqualified native module names. + let loader = ( + host_loader.clone(), + user_modules.clone(), + native_loader, + module_loader, + ); runtime.set_loader(loader.clone(), loader); context.with(|ctx| -> anyhow::Result<()> { @@ -96,6 +158,7 @@ impl JsRuntime { Ok(Self { context, handlers: HashMap::new(), + user_modules, }) } @@ -190,6 +253,37 @@ impl JsRuntime { Ok(()) } + /// Register a user module with the runtime. + /// + /// The module source is stored for lazy compilation — it will be compiled + /// and evaluated by QuickJS the first time it is imported by a handler or + /// another user module. This avoids ordering issues between interdependent + /// modules. + /// + /// The `module_name` should be the fully qualified name (e.g. `user:utils`) + /// that guest JavaScript will use in `import` statements. + /// + /// # Validation + /// + /// The name must not be empty. Primary validation (colons, reserved + /// namespaces, duplicates) is enforced by the host-side `JSSandbox` layer; + /// this check provides defense-in-depth at the guest boundary. + pub fn register_module( + &mut self, + module_name: impl Into, + module_source: impl Into, + ) -> anyhow::Result<()> { + let module_name = module_name.into(); + if module_name.is_empty() { + anyhow::bail!("Module name must not be empty"); + } + self.user_modules + .modules + .borrow_mut() + .insert(module_name, module_source.into()); + Ok(()) + } + /// Run a registered handler function with the given event data. /// 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. /// The result is returned as a JSON string. diff --git a/src/hyperlight-js-runtime/src/main/hyperlight.rs b/src/hyperlight-js-runtime/src/main/hyperlight.rs index 350ab50..46f528f 100644 --- a/src/hyperlight-js-runtime/src/main/hyperlight.rs +++ b/src/hyperlight-js-runtime/src/main/hyperlight.rs @@ -92,6 +92,15 @@ fn register_handler( Ok(()) } +#[guest_function("register_module")] +#[instrument(skip_all, level = "info")] +fn register_module(module_name: String, module_source: String) -> Result<()> { + RUNTIME + .lock() + .register_module(module_name, module_source)?; + Ok(()) +} + #[host_function("CallHostJsFunction")] fn call_host_js_function(module_name: String, func_name: String, args: String) -> Result; diff --git a/src/hyperlight-js/Cargo.toml b/src/hyperlight-js/Cargo.toml index 6ab9e98..accb3b9 100644 --- a/src/hyperlight-js/Cargo.toml +++ b/src/hyperlight-js/Cargo.toml @@ -111,6 +111,11 @@ name = "runtime_debugging" path = "examples/runtime_debugging/main.rs" test = false +[[example]] +name = "user_modules" +path = "examples/user_modules/main.rs" +test = false + [[bench]] name = "benchmarks" harness = false diff --git a/src/hyperlight-js/examples/user_modules/main.rs b/src/hyperlight-js/examples/user_modules/main.rs new file mode 100644 index 0000000..3975094 --- /dev/null +++ b/src/hyperlight-js/examples/user_modules/main.rs @@ -0,0 +1,269 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! User Modules example — register reusable ES modules that handlers can share. +//! +//! Demonstrates: +//! - Registering user modules with `add_module()` +//! - Handlers importing a shared module via `import { ... } from 'user:'` +//! - **Cross-handler mutable state sharing** via a shared module +//! - Inter-module dependencies +//! - Custom namespaces +//! +//! Run with: +//! ```bash +//! cargo run --example user_modules +//! ``` + +#![allow(clippy::disallowed_macros)] + +use anyhow::Result; +use hyperlight_js::{SandboxBuilder, Script}; + +fn main() -> Result<()> { + println!("=== Hyperlight JS User Modules ===\n"); + + // ── Build sandbox ──────────────────────────────────────────────── + println!("1. Creating sandbox..."); + let proto = SandboxBuilder::new().build()?; + let mut sandbox = proto.load_runtime()?; + println!(" ✓ Sandbox created\n"); + + // ── Part 1: Shared pure-function module ────────────────────────── + println!("2. Registering user modules..."); + + // A constants module — other modules can import from it + sandbox.add_module( + "constants", + Script::from_content( + r#" + export const PI = 3.14159; + export const E = 2.71828; + "#, + ), + )?; + + // A geometry module that depends on the constants module + sandbox.add_module( + "geometry", + Script::from_content( + r#" + import { PI } from 'user:constants'; + export function circleArea(radius) { return PI * radius * radius; } + export function circleCircumference(radius) { return 2 * PI * radius; } + "#, + ), + )?; + + // A string utils module with a custom namespace + sandbox.add_module_ns( + "strings", + Script::from_content( + r#" + export function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1); + } + export function reverse(s) { + return s.split('').reverse().join(''); + } + "#, + ), + "mylib", + )?; + + println!(" ✓ 3 pure-function modules registered\n"); + + // ── Part 2: Shared mutable state module ────────────────────────── + // + // This is the key pattern — a module with mutable state that + // multiple handlers can read and write. ESM singleton semantics + // guarantee all importers see the same module instance. + + // A counter module with mutable module-level state + sandbox.add_module( + "counter", + Script::from_content( + r#" + let count = 0; + export function increment() { return ++count; } + export function getCount() { return count; } + "#, + ), + )?; + + // A key-value store module for richer shared state + sandbox.add_module( + "store", + Script::from_content( + r#" + const data = new Map(); + export function set(key, value) { data.set(key, value); } + export function get(key) { return data.get(key); } + export function entries() { return Object.fromEntries(data); } + "#, + ), + )?; + + println!(" ✓ 2 shared-state modules registered\n"); + + // ── Register handlers ──────────────────────────────────────────── + println!("3. Adding handlers..."); + + // Handler: geometry (uses pure functions via inter-module deps) + sandbox.add_handler( + "circle", + Script::from_content( + r#" + import { circleArea, circleCircumference } from 'user:geometry'; + export function handler(event) { + return { + radius: event.radius, + area: circleArea(event.radius), + circumference: circleCircumference(event.radius), + }; + } + "#, + ), + )?; + + // Handler: string processing (custom namespace) + sandbox.add_handler( + "strings", + Script::from_content( + r#" + import { capitalize, reverse } from 'mylib:strings'; + export function handler(event) { + return { + original: event.text, + capitalized: capitalize(event.text), + reversed: reverse(event.text), + }; + } + "#, + ), + )?; + + // Handler: counter writer — mutates the shared counter module + sandbox.add_handler( + "counter_writer", + Script::from_content( + r#" + import { increment } from 'user:counter'; + export function handler(event) { + event.count = increment(); + return event; + } + "#, + ), + )?; + + // Handler: counter reader — reads state WITHOUT mutating it + sandbox.add_handler( + "counter_reader", + Script::from_content( + r#" + import { getCount } from 'user:counter'; + export function handler(event) { + event.count = getCount(); + return event; + } + "#, + ), + )?; + + // Handler: store writer — writes key-value pairs to the shared store + sandbox.add_handler( + "store_put", + Script::from_content( + r#" + import { set } from 'user:store'; + export function handler(event) { + set(event.key, event.value); + return { ok: true }; + } + "#, + ), + )?; + + // Handler: store reader — reads back from the shared store + sandbox.add_handler( + "store_get", + Script::from_content( + r#" + import { get, entries } from 'user:store'; + export function handler(event) { + return { + value: get(event.key), + all: entries(), + }; + } + "#, + ), + )?; + + let mut loaded = sandbox.get_loaded_sandbox()?; + println!(" ✓ 6 handlers loaded\n"); + + // ── Call pure-function handlers ─────────────────────────────────── + println!("4. Calling pure-function handlers...\n"); + + let circle = loaded.handle_event("circle", r#"{"radius": 5}"#.to_string(), None)?; + println!(" Circle (radius=5): {circle}"); + + let strings = loaded.handle_event("strings", r#"{"text": "hyperlight"}"#.to_string(), None)?; + println!(" Strings (\"hyperlight\"): {strings}\n"); + + // ── Demonstrate cross-handler shared mutable state ──────────────── + println!("5. Cross-handler shared mutable state (counter)...\n"); + + let r1 = loaded.handle_event("counter_writer", "{}".to_string(), None)?; + println!(" Writer call 1 → {r1}"); + + let r2 = loaded.handle_event("counter_reader", "{}".to_string(), None)?; + println!(" Reader sees → {r2} (should match writer's count)"); + + let r3 = loaded.handle_event("counter_writer", "{}".to_string(), None)?; + println!(" Writer call 2 → {r3}"); + + let r4 = loaded.handle_event("counter_reader", "{}".to_string(), None)?; + println!(" Reader sees → {r4} (should match writer's count)\n"); + + // ── Demonstrate cross-handler shared key-value store ────────────── + println!("6. Cross-handler shared mutable state (key-value store)...\n"); + + loaded.handle_event( + "store_put", + r#"{"key": "name", "value": "Hyperlight"}"#.to_string(), + None, + )?; + println!(" Put: name = \"Hyperlight\""); + + loaded.handle_event( + "store_put", + r#"{"key": "year", "value": 1985}"#.to_string(), + None, + )?; + println!(" Put: year = 1985"); + + let store_result = loaded.handle_event("store_get", r#"{"key": "name"}"#.to_string(), None)?; + println!(" Get(name): {store_result}"); + + let store_all = loaded.handle_event("store_get", r#"{"key": "year"}"#.to_string(), None)?; + println!(" Get(year): {store_all}"); + + println!("\n✅ User modules example complete! — \"Life moves pretty fast. If you don't stop and share state once in a while, you could miss it.\""); + Ok(()) +} diff --git a/src/hyperlight-js/src/lib.rs b/src/hyperlight-js/src/lib.rs index 916faba..37d6d32 100644 --- a/src/hyperlight-js/src/lib.rs +++ b/src/hyperlight-js/src/lib.rs @@ -29,6 +29,8 @@ pub mod sandbox; use hyperlight_host::func::HostFunction; /// A Hyperlight Sandbox with a JavaScript run time loaded but no guest code. pub use sandbox::js_sandbox::JSSandbox; +/// Default namespace for user modules added via [`JSSandbox::add_module`]. +pub use sandbox::js_sandbox::DEFAULT_MODULE_NAMESPACE; /// A Hyperlight Sandbox with a JavaScript run time loaded and guest code loaded. pub use sandbox::loaded_js_sandbox::LoadedJSSandbox; /// A Hyperlight Sandbox with no JavaScript run time loaded and no guest code. diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 09cf683..db6cc2e 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -25,10 +25,28 @@ use super::loaded_js_sandbox::LoadedJSSandbox; use crate::sandbox::metrics::SandboxMetricsGuard; use crate::Script; +/// Default namespace for user modules when none is specified. +/// +/// Guest JavaScript imports user modules as `import { ... } from "user:"`. +/// This namespace is used by [`JSSandbox::add_module`] when no custom namespace +/// is provided. +/// +/// Note: `"user"` is the default namespace but is **not** reserved — callers +/// can pass it explicitly to [`JSSandbox::add_module_ns`] with the same effect +/// as calling [`JSSandbox::add_module`]. +pub const DEFAULT_MODULE_NAMESPACE: &str = "user"; + +/// Reserved namespaces that cannot be used for user modules. +/// The `host` namespace is reserved for host-function modules registered via +/// [`ProtoJSSandbox::host_module`]. +const RESERVED_NAMESPACES: &[&str] = &["host"]; + /// A Hyperlight Sandbox with a JavaScript run time loaded but no guest code. pub struct JSSandbox { pub(super) inner: MultiUseSandbox, handlers: HashMap, + /// User modules keyed by qualified name (e.g. `user:utils`). + modules: HashMap, // Snapshot of state before any handlers are added. // This is used to restore state back to a neutral JSSandbox. snapshot: Arc, @@ -43,6 +61,7 @@ impl JSSandbox { Ok(Self { inner, handlers: HashMap::new(), + modules: HashMap::new(), snapshot, _metric_guard: SandboxMetricsGuard::new(), }) @@ -57,6 +76,7 @@ impl JSSandbox { Ok(Self { inner: loaded, handlers: HashMap::new(), + modules: HashMap::new(), snapshot, _metric_guard: SandboxMetricsGuard::new(), }) @@ -105,6 +125,149 @@ impl JSSandbox { self.handlers.clear(); } + // ── Module management ──────────────────────────────────────────── + + /// Adds a module to the sandbox with the default namespace (`user`). + /// + /// The module will be available for import by handlers (and other modules) + /// using `import { ... } from 'user:'`. + /// + /// Modules are compiled lazily when first imported, so inter-module + /// dependencies are resolved automatically regardless of registration order. + /// + /// # Shared state between handlers + /// + /// ES modules are singletons — all importers share the **same** module + /// instance. This means mutable module-level state (e.g. `let count = 0`) + /// is visible to every handler that imports the module. Handler A can + /// mutate module state, and Handler B will see those changes in + /// subsequent calls. + /// + /// Module state persists across [`LoadedJSSandbox::handle_event()`] calls + /// and is reset by [`LoadedJSSandbox::snapshot()`] / [`LoadedJSSandbox::restore()`] + /// or [`LoadedJSSandbox::unload()`]. + /// + /// # Example + /// + /// ```text + /// // Register a utility module (pure functions) + /// sandbox.add_module("utils", Script::from_content( + /// "export function greet(name) { return `hello ${name}`; }" + /// ))?; + /// // Handler can import it: + /// // import { greet } from 'user:utils'; + /// + /// // Register a module with mutable shared state + /// sandbox.add_module("counter", Script::from_content( + /// "let count = 0;\nexport function increment() { return ++count; }\nexport function getCount() { return count; }" + /// ))?; + /// // Multiple handlers import the same module and share its state: + /// // Handler A: import { increment } from 'user:counter'; → mutates count + /// // Handler B: import { getCount } from 'user:counter'; → reads count + /// ``` + #[instrument(err(Debug), skip(self, script), level=Level::DEBUG)] + pub fn add_module + std::fmt::Debug>( + &mut self, + module_name: N, + script: Script, + ) -> Result<()> { + self.add_module_ns(module_name, script, DEFAULT_MODULE_NAMESPACE) + } + + /// Adds a module to the sandbox with a custom namespace. + /// + /// The module will be available for import by handlers (and other modules) + /// using `import { ... } from ':'`. + /// + /// Like [`JSSandbox::add_module`], modules are ES module singletons — + /// mutable state is shared across all importing handlers. See + /// [`JSSandbox::add_module`] for details on state sharing and lifecycle. + /// + /// # Namespace restrictions + /// + /// - Must not be empty + /// - Must not contain `':'` + /// - Must not be a reserved namespace (e.g. `"host"`) + /// + /// # Example + /// + /// ```text + /// sandbox.add_module_ns("math", script, "mylib")?; + /// // Handler imports: import { add } from 'mylib:math'; + /// ``` + #[instrument(err(Debug), skip(self, script), level=Level::DEBUG)] + pub fn add_module_ns( + &mut self, + module_name: N, + script: Script, + namespace: NS, + ) -> Result<()> + where + N: Into + std::fmt::Debug, + NS: Into + std::fmt::Debug, + { + let module_name = module_name.into(); + let namespace = namespace.into(); + + if module_name.is_empty() { + return Err(new_error!("Module name must not be empty")); + } + if namespace.is_empty() { + return Err(new_error!("Module namespace must not be empty")); + } + if module_name.contains(':') { + return Err(new_error!("Module name must not contain ':'")); + } + if namespace.contains(':') { + return Err(new_error!("Module namespace must not contain ':'")); + } + if RESERVED_NAMESPACES.contains(&namespace.as_str()) { + return Err(new_error!("Module namespace '{}' is reserved", namespace)); + } + + let qualified_name = format!("{}:{}", namespace, module_name); + if self.modules.contains_key(&qualified_name) { + return Err(new_error!("Module already exists: {}", qualified_name)); + } + + self.modules.insert(qualified_name, script); + Ok(()) + } + + /// Removes a module from the sandbox (using the default namespace). + #[instrument(err(Debug), skip(self), level=Level::DEBUG)] + pub fn remove_module(&mut self, module_name: &str) -> Result<()> { + self.remove_module_ns(module_name, DEFAULT_MODULE_NAMESPACE) + } + + /// Removes a module from the sandbox (using a custom namespace). + #[instrument(err(Debug), skip(self), level=Level::DEBUG)] + pub fn remove_module_ns(&mut self, module_name: &str, namespace: &str) -> Result<()> { + if module_name.is_empty() { + return Err(new_error!("Module name must not be empty")); + } + if namespace.is_empty() { + return Err(new_error!("Module namespace must not be empty")); + } + if module_name.contains(':') { + return Err(new_error!("Module name must not contain ':'")); + } + if namespace.contains(':') { + return Err(new_error!("Module namespace must not contain ':'")); + } + let qualified_name = format!("{namespace}:{module_name}"); + match self.modules.remove(&qualified_name) { + Some(_) => Ok(()), + None => Err(new_error!("Module does not exist: {}", qualified_name)), + } + } + + /// Clears all modules from the sandbox. + #[instrument(skip_all, level=Level::TRACE)] + pub fn clear_modules(&mut self) { + self.modules.clear(); + } + /// Returns whether the sandbox is currently poisoned. /// /// A poisoned sandbox is in an inconsistent state due to the guest not running to completion. @@ -120,15 +283,36 @@ impl JSSandbox { self.handlers.len() } + #[cfg(test)] + fn get_number_of_modules(&self) -> usize { + self.modules.len() + } + /// Creates a new `LoadedJSSandbox` with the handlers that have been added to this `JSSandbox`. + /// + /// # Partial failure + /// + /// This method consumes `self`. If module registration succeeds but a handler + /// fails to register, the `JSSandbox` is lost and the caller receives an error. + /// To recover, create a new sandbox via `SandboxBuilder`. This is consistent with + /// the existing handler-only behaviour and the one-shot consumption pattern. #[instrument(err(Debug), skip_all, level=Level::TRACE)] pub fn get_loaded_sandbox(mut self) -> Result { if self.handlers.is_empty() { return Err(new_error!("No handlers have been added to the sandbox")); } - let handlers = self.handlers.clone(); - for (function_name, script) in handlers { + // Register user modules first so that handlers can import them. + // NOTE: HashMap iteration order is non-deterministic, but this is safe + // because modules are lazily compiled by the UserModuleLoader when first + // imported — registration order does not affect resolution. + for (qualified_name, script) in std::mem::take(&mut self.modules) { + let content = script.content().to_owned(); + self.inner + .call::<()>("register_module", (qualified_name, content))?; + } + + for (function_name, script) in std::mem::take(&mut self.handlers) { let content = script.content().to_owned(); let path = script @@ -186,6 +370,7 @@ impl Debug for JSSandbox { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("JSSandbox") .field("handlers", &self.handlers) + .field("modules", &self.modules) .finish() } } @@ -248,4 +433,152 @@ mod tests { let res = sandbox.get_loaded_sandbox(); assert!(res.is_ok()); } + + // ── Module unit tests ──────────────────────────────────────────── + + #[test] + fn test_add_module() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox + .add_module("utils", Script::from_content("export const x = 1;")) + .unwrap(); + sandbox + .add_module("helpers", Script::from_content("export const y = 2;")) + .unwrap(); + + assert_eq!(sandbox.get_number_of_modules(), 2); + } + + #[test] + fn test_add_module_with_custom_namespace() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox + .add_module_ns( + "math", + Script::from_content("export const PI = 3.14;"), + "mylib", + ) + .unwrap(); + + assert_eq!(sandbox.get_number_of_modules(), 1); + } + + #[test] + fn test_add_module_rejects_empty_name() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let res = sandbox.add_module("", Script::from_content("export const x = 1;")); + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("must not be empty")); + } + + #[test] + fn test_add_module_rejects_empty_namespace() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let res = sandbox.add_module_ns("utils", Script::from_content("export const x = 1;"), ""); + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("must not be empty")); + } + + #[test] + fn test_add_module_rejects_colon_in_name() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let res = sandbox.add_module("bad:name", Script::from_content("export const x = 1;")); + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("must not contain ':'")); + } + + #[test] + fn test_add_module_rejects_colon_in_namespace() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let res = sandbox.add_module_ns( + "utils", + Script::from_content("export const x = 1;"), + "bad:ns", + ); + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("must not contain ':'")); + } + + #[test] + fn test_add_module_rejects_reserved_namespace() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let res = + sandbox.add_module_ns("utils", Script::from_content("export const x = 1;"), "host"); + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("reserved")); + } + + #[test] + fn test_add_module_rejects_duplicate() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox + .add_module("utils", Script::from_content("export const x = 1;")) + .unwrap(); + let res = sandbox.add_module("utils", Script::from_content("export const y = 2;")); + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("already exists")); + } + + #[test] + fn test_remove_module() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox + .add_module("utils", Script::from_content("export const x = 1;")) + .unwrap(); + sandbox.remove_module("utils").unwrap(); + + assert_eq!(sandbox.get_number_of_modules(), 0); + } + + #[test] + fn test_remove_module_with_custom_namespace() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox + .add_module_ns( + "math", + Script::from_content("export const PI = 3.14;"), + "mylib", + ) + .unwrap(); + sandbox.remove_module_ns("math", "mylib").unwrap(); + + assert_eq!(sandbox.get_number_of_modules(), 0); + } + + #[test] + fn test_clear_modules() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox + .add_module("a", Script::from_content("export const x = 1;")) + .unwrap(); + sandbox + .add_module("b", Script::from_content("export const y = 2;")) + .unwrap(); + + sandbox.clear_modules(); + + assert_eq!(sandbox.get_number_of_modules(), 0); + } } diff --git a/src/hyperlight-js/tests/user_modules.rs b/src/hyperlight-js/tests/user_modules.rs new file mode 100644 index 0000000..010ea2d --- /dev/null +++ b/src/hyperlight-js/tests/user_modules.rs @@ -0,0 +1,742 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +//! Integration tests for user module registration via `add_module` / `add_module_ns`. +//! +//! These tests exercise the full lifecycle: host-side registration → guest-side +//! lazy compilation → handler import → execution. + +#![allow(clippy::disallowed_macros)] + +use hyperlight_js::{SandboxBuilder, Script}; + +// ── Basic import ───────────────────────────────────────────────────── + +#[test] +fn handler_imports_user_module_with_default_namespace() { + let math_module = Script::from_content( + r#" + export function add(a, b) { return a + b; } + export function multiply(a, b) { return a * b; } + "#, + ); + + let handler = Script::from_content( + r#" + import { add, multiply } from 'user:math'; + export function handler(event) { + event.sum = add(event.a, event.b); + event.product = multiply(event.a, event.b); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("math", math_module).unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("handler", r#"{"a": 5, "b": 3}"#.to_string(), None) + .unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["sum"], 8); + assert_eq!(json["product"], 15); +} + +#[test] +fn handler_imports_user_module_with_custom_namespace() { + let math_module = Script::from_content( + r#" + export function add(a, b) { return a + b; } + "#, + ); + + let handler = Script::from_content( + r#" + import { add } from 'mylib:math'; + export function handler(event) { + event.result = add(event.a, event.b); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module_ns("math", math_module, "mylib").unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("handler", r#"{"a": 10, "b": 20}"#.to_string(), None) + .unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["result"], 30); +} + +// ── Inter-module dependencies ──────────────────────────────────────── + +#[test] +fn module_imports_another_module() { + let constants_module = Script::from_content( + r#" + export const PI = 3.14159; + "#, + ); + + let geometry_module = Script::from_content( + r#" + import { PI } from 'user:constants'; + export function circleArea(r) { return PI * r * r; } + "#, + ); + + let handler = Script::from_content( + r#" + import { circleArea } from 'user:geometry'; + export function handler(event) { + event.area = circleArea(event.radius); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + // Registration order doesn't matter — modules are lazily compiled + sandbox.add_module("geometry", geometry_module).unwrap(); + sandbox.add_module("constants", constants_module).unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("handler", r#"{"radius": 5}"#.to_string(), None) + .unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let area = json["area"].as_f64().unwrap(); + // PI * 5 * 5 = 78.53975 + assert!( + (area - 78.53975).abs() < 0.001, + "Expected ~78.53975, got {area}" + ); +} + +// ── State retention ────────────────────────────────────────────────── + +#[test] +fn module_state_persists_between_handler_calls() { + let counter_module = Script::from_content( + r#" + let count = 0; + export function increment() { return ++count; } + "#, + ); + + let handler = Script::from_content( + r#" + import { increment } from 'user:counter'; + export function handler(event) { + event.count = increment(); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("counter", counter_module).unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + let result1 = loaded + .handle_event("handler", "{}".to_string(), None) + .unwrap(); + let json1: serde_json::Value = serde_json::from_str(&result1).unwrap(); + assert_eq!(json1["count"], 1); + + let result2 = loaded + .handle_event("handler", "{}".to_string(), None) + .unwrap(); + let json2: serde_json::Value = serde_json::from_str(&result2).unwrap(); + assert_eq!(json2["count"], 2); +} + +// ── Unload / reload ────────────────────────────────────────────────── + +#[test] +fn unload_and_reload_with_different_module_version() { + let math_v1 = Script::from_content(r#"export function compute(x) { return x + 1; }"#); + let math_v2 = Script::from_content(r#"export function compute(x) { return x * 2; }"#); + + let handler_src = r#" + import { compute } from 'user:math'; + export function handler(event) { + event.result = compute(event.x); + return event; + } + "#; + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("math", math_v1).unwrap(); + sandbox + .add_handler("handler", Script::from_content(handler_src)) + .unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("handler", r#"{"x": 5}"#.to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["result"], 6); // 5 + 1 + + // Unload, swap module, reload + let mut sandbox = loaded.unload().unwrap(); + sandbox.add_module("math", math_v2).unwrap(); + sandbox + .add_handler("handler", Script::from_content(handler_src)) + .unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("handler", r#"{"x": 5}"#.to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["result"], 10); // 5 * 2 +} + +// ── Validation ─────────────────────────────────────────────────────── + +#[test] +fn add_module_rejects_empty_name() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let result = sandbox.add_module("", Script::from_content("export const x = 1;")); + assert!(result.is_err(), "Empty module name should be rejected"); + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("must not be empty"), + "Error should mention empty name, got: {err}" + ); +} + +#[test] +fn add_module_rejects_reserved_namespace() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let result = + sandbox.add_module_ns("utils", Script::from_content("export const x = 1;"), "host"); + assert!(result.is_err(), "Reserved namespace should be rejected"); + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("reserved"), + "Error should mention reserved namespace, got: {err}" + ); +} + +#[test] +fn add_module_rejects_duplicate() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox + .add_module("utils", Script::from_content("export const x = 1;")) + .unwrap(); + let result = sandbox.add_module("utils", Script::from_content("export const y = 2;")); + assert!(result.is_err(), "Duplicate module should be rejected"); + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("already exists"), + "Error should mention duplicate, got: {err}" + ); +} + +#[test] +fn remove_module_rejects_empty_name() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let result = sandbox.remove_module(""); + assert!(result.is_err(), "Empty module name should be rejected"); + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("must not be empty"), + "Error should mention empty name, got: {err}" + ); +} + +#[test] +fn remove_module_rejects_nonexistent_module() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let result = sandbox.remove_module("nonexistent"); + assert!(result.is_err(), "Non-existent module removal should fail"); + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("does not exist"), + "Error should mention non-existence, got: {err}" + ); +} + +#[test] +fn remove_module_ns_rejects_empty_namespace() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + let result = sandbox.remove_module_ns("utils", ""); + assert!(result.is_err(), "Empty namespace should be rejected"); + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("must not be empty"), + "Error should mention empty namespace, got: {err}" + ); +} + +// ── Multiple handlers sharing mutable state via a module ───────────── + +/// Proves that two handlers importing the same module see the **same** mutable +/// state. Handler A mutates module-level state and Handler B reads it, +/// confirming ESM singleton semantics deliver cross-handler state sharing. +#[test] +fn multiple_handlers_share_mutable_module_state() { + // Shared module with mutable state: a simple counter. + let counter_module = Script::from_content( + r#" + let count = 0; + export function increment() { return ++count; } + export function getCount() { return count; } + "#, + ); + + // Handler A: mutates state by calling increment() + let writer_handler = Script::from_content( + r#" + import { increment } from 'user:counter'; + export function handler(event) { + event.count = increment(); + return event; + } + "#, + ); + + // Handler B: reads state without mutating it + let reader_handler = Script::from_content( + r#" + import { getCount } from 'user:counter'; + export function handler(event) { + event.count = getCount(); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("counter", counter_module).unwrap(); + sandbox.add_handler("writer", writer_handler).unwrap(); + sandbox.add_handler("reader", reader_handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + // writer increments → count=1 + let result = loaded + .handle_event("writer", "{}".to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + json["count"], 1, + "writer should see count=1 after first increment" + ); + + // reader sees the mutation made by writer → count=1 + let result = loaded + .handle_event("reader", "{}".to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + json["count"], 1, + "reader should see count=1 written by writer" + ); + + // writer increments again → count=2 + let result = loaded + .handle_event("writer", "{}".to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + json["count"], 2, + "writer should see count=2 after second increment" + ); + + // reader sees the updated state → count=2 + let result = loaded + .handle_event("reader", "{}".to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + json["count"], 2, + "reader should see count=2 written by writer" + ); +} + +// ── Multiple handlers sharing a module (pure functions) ────────────── + +#[test] +fn multiple_handlers_can_share_a_module() { + let utils_module = Script::from_content( + r#" + export function double(x) { return x * 2; } + export function triple(x) { return x * 3; } + "#, + ); + + let double_handler = Script::from_content( + r#" + import { double } from 'user:utils'; + export function handler(event) { + event.result = double(event.x); + return event; + } + "#, + ); + + let triple_handler = Script::from_content( + r#" + import { triple } from 'user:utils'; + export function handler(event) { + event.result = triple(event.x); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("utils", utils_module).unwrap(); + sandbox.add_handler("doubler", double_handler).unwrap(); + sandbox.add_handler("tripler", triple_handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + let result = loaded + .handle_event("doubler", r#"{"x": 7}"#.to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["result"], 14); + + let result = loaded + .handle_event("tripler", r#"{"x": 7}"#.to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["result"], 21); +} + +// ── User module importing a built-in module ────────────────────────── + +#[test] +fn user_module_can_import_builtin_module() { + let hasher_module = Script::from_content( + r#" + import { createHmac } from 'crypto'; + export function hmac(data) { + return createHmac('sha256', 'secret').update(data).digest('hex'); + } + "#, + ); + + let handler = Script::from_content( + r#" + import { hmac } from 'user:hasher'; + export function handler(event) { + event.hash = hmac(event.data); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("hasher", hasher_module).unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("handler", r#"{"data": "hello"}"#.to_string(), None) + .unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let hash = json["hash"].as_str().unwrap(); + assert!(!hash.is_empty(), "Hash should not be empty"); + // SHA-256 HMAC hex output is always 64 characters + assert_eq!(hash.len(), 64, "SHA-256 HMAC hex should be 64 chars"); +} + +// ── User module importing a host function ──────────────────────────── + +#[test] +fn user_module_can_import_host_function() { + let enricher_module = Script::from_content( + r#" + import * as db from 'host:db'; + export function enrich(event) { + const user = db.lookup(event.userId); + event.userName = user.name; + return event; + } + "#, + ); + + let handler = Script::from_content( + r#" + import { enrich } from 'user:enricher'; + export function handler(event) { + return enrich(event); + } + "#, + ); + + let mut proto = SandboxBuilder::new().build().unwrap(); + proto.host_module("host:db").register_raw( + "lookup", + |args: String| -> hyperlight_js::Result { + let parsed: serde_json::Value = serde_json::from_str(&args).unwrap(); + let id = parsed[0].as_i64().unwrap(); + let result = serde_json::json!({ "id": id, "name": format!("User {}", id) }); + Ok(serde_json::to_string(&result).unwrap()) + }, + ); + + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("enricher", enricher_module).unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("handler", r#"{"userId": 42}"#.to_string(), None) + .unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["userName"], "User 42"); +} + +// ── Error on missing module ────────────────────────────────────────── + +#[test] +fn handler_fails_when_importing_nonexistent_module() { + let handler = Script::from_content( + r#" + import { foo } from 'user:nonexistent'; + export function handler(event) { + event.result = foo(); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + // Loading should fail because the module doesn't exist + let result = sandbox.get_loaded_sandbox(); + assert!( + result.is_err(), + "Should fail when handler imports a non-existent module" + ); +} + +// ── Circular module dependencies ───────────────────────────────────── + +#[test] +fn circular_module_imports_work() { + // ESM circular imports are well-defined: live bindings resolve after evaluation. + let module_a = Script::from_content( + r#" + import { getY } from 'user:moduleB'; + export function getX() { return 'X'; } + export function getXY() { return getX() + getY(); } + "#, + ); + + let module_b = Script::from_content( + r#" + import { getX } from 'user:moduleA'; + export function getY() { return 'Y'; } + export function getYX() { return getY() + getX(); } + "#, + ); + + let handler = Script::from_content( + r#" + import { getXY } from 'user:moduleA'; + import { getYX } from 'user:moduleB'; + export function handler(event) { + return { xy: getXY(), yx: getYX() }; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("moduleA", module_a).unwrap(); + sandbox.add_module("moduleB", module_b).unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("handler", "{}".to_string(), None) + .unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["xy"], "XY"); + assert_eq!(json["yx"], "YX"); +} + +// ── Snapshot / restore interaction with modules ────────────────────── + +#[test] +fn snapshot_restore_resets_module_state() { + let counter_module = Script::from_content( + r#" + let count = 0; + export function increment() { return ++count; } + "#, + ); + + let handler = Script::from_content( + r#" + import { increment } from 'user:counter'; + export function handler(event) { + event.count = increment(); + return event; + } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("counter", counter_module).unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + // Call once → count=1 + let result = loaded + .handle_event("handler", "{}".to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["count"], 1); + + // Snapshot after count=1 + let snapshot = loaded.snapshot().unwrap(); + + // Call again → count=2 + let result = loaded + .handle_event("handler", "{}".to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["count"], 2); + + // Restore → back to count=1 + loaded.restore(snapshot).unwrap(); + + // Call again → count=2 (restored to post-snapshot state, so next call is 2) + let result = loaded + .handle_event("handler", "{}".to_string(), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(json["count"], 2); +} + +// ── Module with syntax error ───────────────────────────────────────── + +#[test] +fn module_with_syntax_error_fails_at_load() { + let bad_module = Script::from_content("export function broken( { NOPE }"); + + let handler = Script::from_content( + r#" + import { broken } from 'user:bad'; + export function handler(event) { return event; } + "#, + ); + + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox.add_module("bad", bad_module).unwrap(); + sandbox.add_handler("handler", handler).unwrap(); + + let result = sandbox.get_loaded_sandbox(); + assert!( + result.is_err(), + "Should fail when module has a syntax error" + ); +} + +// ── Remove then load lifecycle ─────────────────────────────────────── + +#[test] +fn removed_module_is_unavailable_on_load() { + let proto = SandboxBuilder::new().build().unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + + sandbox + .add_module( + "utils", + Script::from_content("export function greet() { return 'hi'; }"), + ) + .unwrap(); + sandbox.remove_module("utils").unwrap(); + + sandbox + .add_handler( + "handler", + Script::from_content( + r#" + import { greet } from 'user:utils'; + export function handler(event) { event.msg = greet(); return event; } + "#, + ), + ) + .unwrap(); + + let result = sandbox.get_loaded_sandbox(); + assert!( + result.is_err(), + "Should fail when handler imports a removed module" + ); +} diff --git a/src/js-host-api/README.md b/src/js-host-api/README.md index d3c2079..71a6c28 100644 --- a/src/js-host-api/README.md +++ b/src/js-host-api/README.md @@ -85,17 +85,39 @@ const jsSandbox = await protoSandbox.loadRuntime(); ### JSSandbox -A sandbox with the JavaScript runtime loaded, ready for handlers. +A sandbox with the JavaScript runtime loaded, ready for handlers and modules. -**Methods:** +**Handler Methods:** - `addHandler(name: string, code: string)` — Adds a JavaScript handler function (sync) -- `getLoadedSandbox()` → `Promise` — Gets the loaded sandbox ready to call handlers -- `clearHandlers()` — Clears all registered handlers (sync) - `removeHandler(name: string)` — Removes a specific handler by name (sync) +- `clearHandlers()` — Clears all registered handlers (sync) + +**Module Methods:** +- `addModule(name: string, source: string, namespace?: string)` — Register a reusable ES module (sync). Default namespace is `"user"`, handlers import via `import { ... } from 'user:'` +- `removeModule(name: string, namespace?: string)` — Remove a registered module (sync) +- `clearModules()` — Remove all registered modules (sync) + +**Other Methods:** +- `getLoadedSandbox()` → `Promise` — Compiles all modules and handlers into the guest. Modules are registered first, then handlers. ```javascript -// Add a handler (sync) — routing key can be any name, but the function must be named 'handler' -sandbox.addHandler('myHandler', 'function handler(input) { return input; }'); +// Add a module (sync) +sandbox.addModule('math', ` + export function add(a, b) { return a + b; } +`); + +// Add a handler that imports the module (sync) +sandbox.addHandler('handler', ` + import { add } from 'user:math'; + function handler(event) { + event.result = add(event.a, event.b); + return event; + } +`); + +// With custom namespace +sandbox.addModule('utils', 'export function greet() { return "hi"; }', 'mylib'); +// Handler imports: import { greet } from 'mylib:utils'; // Get loaded sandbox (async) const loaded = await sandbox.getLoadedSandbox(); @@ -533,6 +555,139 @@ This design: - Uses napi-rs `call_with_return_value` for simple async result handling - JSON serialization is handled automatically by the bridge +## User Modules + +User modules let you register reusable ES modules that handlers (and other +modules) can import. This is different from host functions — user modules +run entirely inside the guest micro-VM, while host functions call back into +Node.js. + +### Quick Start + +```javascript +const sandbox = await proto.loadRuntime(); + +// Register a module — uses the default 'user' namespace +sandbox.addModule('math', ` + export function add(a, b) { return a + b; } + export function multiply(a, b) { return a * b; } +`); + +// Handler imports the module using 'user:' +sandbox.addHandler('handler', ` + import { add, multiply } from 'user:math'; + function handler(event) { + return { sum: add(event.a, event.b), product: multiply(event.a, event.b) }; + } +`); +``` + +### Namespace Convention + +Module imports use the format `:`: + +| Namespace | Created by | Example import | +|-----------|-----------|----------------| +| `user` | `addModule()` (default) | `import { add } from 'user:math'` | +| Custom | `addModule(name, source, namespace)` | `import { greet } from 'mylib:utils'` | +| `host` | `hostModule().register()` (reserved) | `import * as db from 'host:db'` | + +The `host` namespace is reserved — you cannot use it for user modules. + +### Inter-Module Dependencies + +User modules can import other user modules. Modules are compiled lazily +when first imported, so registration order doesn't matter: + +```javascript +// Register in any order — dependencies are resolved automatically +sandbox.addModule('geometry', ` + import { PI } from 'user:constants'; + export function circleArea(r) { return PI * r * r; } +`); + +sandbox.addModule('constants', ` + export const PI = 3.14159; +`); +``` + +### What Can User Modules Import? + +| Source | Allowed? | Example | +|--------|----------|---------| +| Other user modules | ✅ Yes | `import { PI } from 'user:constants'` | +| Built-in modules | ✅ Yes | `import { createHmac } from 'crypto'` | +| Host function modules | ✅ Yes | `import * as db from 'host:db'` | +| Non-existent modules | ❌ Runtime error | `import { x } from 'user:nope'` → fails at `getLoadedSandbox()` | + +### State Retention + +Module-level state (variables, closures) persists between handler calls, +just like handler-level state. Use `snapshot()`/`restore()` to reset: + +```javascript +sandbox.addModule('counter', ` + let count = 0; + export function increment() { return ++count; } +`); +// First call → 1, second call → 2, etc. +``` + +### Shared State Between Handlers + +ES modules are **singletons** — every handler that imports a module gets +the **same** instance with the **same** mutable state. This means Handler A +can write state that Handler B reads in a later call: + +```javascript +// Shared mutable state module +sandbox.addModule('counter', ` + let count = 0; + export function increment() { return ++count; } + export function getCount() { return count; } +`); + +// Handler A: mutates state +sandbox.addHandler('writer', ` + import { increment } from 'user:counter'; + function handler(event) { + event.count = increment(); + return event; + } +`); + +// Handler B: reads state (does NOT call increment) +sandbox.addHandler('reader', ` + import { getCount } from 'user:counter'; + function handler(event) { + event.count = getCount(); + return event; + } +`); + +const loaded = await sandbox.getLoadedSandbox(); + +await loaded.callHandler('writer', {}); // → { count: 1 } +await loaded.callHandler('reader', {}); // → { count: 1 } — sees writer's mutation +await loaded.callHandler('writer', {}); // → { count: 2 } +await loaded.callHandler('reader', {}); // → { count: 2 } — sees updated state +``` + +This works for any module-level state: counters, `Map`s, arrays, objects, +closures, etc. The shared state: + +- **Persists** across `callHandler()` calls on the same `LoadedJSSandbox` +- **Resets** when you call `snapshot()`/`restore()` or `unload()` + +### Lifecycle + +Modules are registered on the `JSSandbox`, alongside handlers. When +`getLoadedSandbox()` is called, modules are registered in the guest +**before** handlers, so handlers can import them immediately. + +After `unload()`, both handlers and modules are cleared — you can register +new versions and call `getLoadedSandbox()` again. + ## Examples See the `examples/` directory for complete examples: @@ -557,6 +712,11 @@ Registering sync and async host functions that guest code can call. Demonstrates `hostModule().register()` with spread args, `async` callbacks, and the convenience `register()` API. +### User Modules (`user-modules.js`) +Registering reusable ES modules that handlers can import. Demonstrates inter-module +dependencies, custom namespaces, multiple handlers sharing a module, and +**cross-handler mutable state sharing** via a shared counter module. + ## Requirements - **Node.js** >= 18 diff --git a/src/js-host-api/examples/user-modules.js b/src/js-host-api/examples/user-modules.js new file mode 100644 index 0000000..4d62e7c --- /dev/null +++ b/src/js-host-api/examples/user-modules.js @@ -0,0 +1,191 @@ +// User Modules example — register reusable ES modules that handlers can import +// +// Demonstrates: +// - Registering user modules with `addModule()` +// - Handler importing a user module via `import { ... } from 'user:math'` +// - Inter-module dependencies (geometry imports constants) +// - Custom namespaces +// - Multiple handlers sharing a module +// - **Cross-handler mutable state sharing** via a shared module + +const { SandboxBuilder } = require('../lib.js'); + +async function main() { + console.log('=== Hyperlight JS User Modules ===\n'); + + // ── Build sandbox ──────────────────────────────────────────────── + console.log('1. Creating sandbox...'); + const proto = await new SandboxBuilder() + .setHeapSize(8 * 1024 * 1024) + .setScratchSize(1024 * 1024) + .build(); + const sandbox = await proto.loadRuntime(); + console.log(' ✓ Sandbox created\n'); + + // ── Register user modules ──────────────────────────────────────── + // Modules use ES module syntax (export). They are compiled lazily + // when first imported, so registration order doesn't matter. + console.log('2. Registering user modules...'); + + // A constants module — other modules can import from it + sandbox.addModule( + 'constants', + ` + export const PI = 3.14159; + export const E = 2.71828; + ` + ); + + // A geometry module that depends on the constants module + sandbox.addModule( + 'geometry', + ` + import { PI } from 'user:constants'; + export function circleArea(radius) { return PI * radius * radius; } + export function circleCircumference(radius) { return 2 * PI * radius; } + ` + ); + + // A string utils module with a custom namespace + sandbox.addModule( + 'strings', + ` + export function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1); + } + export function reverse(s) { + return s.split('').reverse().join(''); + } + `, + 'mylib' + ); // custom namespace → import from 'mylib:strings' + + console.log(' ✓ 3 modules registered (2 default namespace, 1 custom)\n'); + + // ── Add handlers that import the modules ───────────────────────── + console.log('3. Adding handlers...'); + + // Handler 1: uses geometry module (which transitively imports constants) + sandbox.addHandler( + 'circle', + ` + import { circleArea, circleCircumference } from 'user:geometry'; + + function handler(event) { + return { + radius: event.radius, + area: circleArea(event.radius), + circumference: circleCircumference(event.radius), + }; + } + ` + ); + + // Handler 2: uses the custom-namespace strings module + sandbox.addHandler( + 'strings', + ` + import { capitalize, reverse } from 'mylib:strings'; + + function handler(event) { + return { + original: event.text, + capitalized: capitalize(event.text), + reversed: reverse(event.text), + }; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + console.log(' ✓ Handlers loaded\n'); + + // ── Call handlers ──────────────────────────────────────────────── + console.log('4. Calling handlers...\n'); + + // Circle handler + const circleResult = await loaded.callHandler('circle', { radius: 5 }); + console.log(' Circle (radius=5):'); + console.log(` Area: ${circleResult.area.toFixed(4)}`); + console.log(` Circumference: ${circleResult.circumference.toFixed(4)}`); + + // Strings handler + const strResult = await loaded.callHandler('strings', { text: 'hyperlight' }); + console.log(`\n Strings ("hyperlight"):`); + console.log(` Capitalized: ${strResult.capitalized}`); + console.log(` Reversed: ${strResult.reversed}`); + + console.log('\n── Part 1 complete (pure-function modules) ──\n'); + + // We need a fresh sandbox for the shared-state demo because + // getLoadedSandbox() consumes the JSSandbox. + console.log('5. Cross-handler shared mutable state...\n'); + const proto2 = await new SandboxBuilder() + .setHeapSize(8 * 1024 * 1024) + .setScratchSize(1024 * 1024) + .build(); + const sandbox2 = await proto2.loadRuntime(); + + // ── Shared mutable state module ────────────────────────────────── + // A counter module with module-level mutable state. ESM singleton + // semantics guarantee all importing handlers see the SAME instance. + sandbox2.addModule( + 'counter', + ` + let count = 0; + export function increment() { return ++count; } + export function getCount() { return count; } + ` + ); + + // Handler A: mutates state by calling increment() + sandbox2.addHandler( + 'writer', + ` + import { increment } from 'user:counter'; + function handler(event) { + event.count = increment(); + return event; + } + ` + ); + + // Handler B: reads state WITHOUT mutating it + sandbox2.addHandler( + 'reader', + ` + import { getCount } from 'user:counter'; + function handler(event) { + event.count = getCount(); + return event; + } + ` + ); + + const loaded2 = await sandbox2.getLoadedSandbox(); + + // Writer increments → count=1 + const w1 = await loaded2.callHandler('writer', {}); + console.log(` Writer call 1 → count=${w1.count}`); + + // Reader sees the mutation made by writer → count=1 + const r1 = await loaded2.callHandler('reader', {}); + console.log(` Reader sees → count=${r1.count} (should be 1)`); + + // Writer increments again → count=2 + const w2 = await loaded2.callHandler('writer', {}); + console.log(` Writer call 2 → count=${w2.count}`); + + // Reader sees the updated state → count=2 + const r2 = await loaded2.callHandler('reader', {}); + console.log(` Reader sees → count=${r2.count} (should be 2)`); + + console.log( + '\n✅ User modules example complete! — "Life moves pretty fast. If you don\'t stop and share state once in a while, you could miss it."' + ); +} + +main().catch((err) => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/src/js-host-api/lib.js b/src/js-host-api/lib.js index 6a82443..7f49501 100644 --- a/src/js-host-api/lib.js +++ b/src/js-host-api/lib.js @@ -155,7 +155,14 @@ for (const method of ['callHandler', 'unload', 'snapshot', 'restore']) { // JSSandbox — async + sync methods + getters JSSandbox.prototype.getLoadedSandbox = wrapAsync(JSSandbox.prototype.getLoadedSandbox); -for (const method of ['addHandler', 'removeHandler', 'clearHandlers']) { +for (const method of [ + 'addHandler', + 'removeHandler', + 'clearHandlers', + 'addModule', + 'removeModule', + 'clearModules', +]) { const orig = JSSandbox.prototype[method]; if (!orig) throw new Error(`Cannot wrap missing method: JSSandbox.${method}`); JSSandbox.prototype[method] = wrapSync(orig); diff --git a/src/js-host-api/src/lib.rs b/src/js-host-api/src/lib.rs index 8c4caa9..4e5c5da 100644 --- a/src/js-host-api/src/lib.rs +++ b/src/js-host-api/src/lib.rs @@ -199,6 +199,55 @@ fn validate_module_name(name: &str) -> napi::Result<()> { Ok(()) } +/// Maximum allowed length for a module or namespace identifier. +/// Guards against accidental multi-MB strings being passed as names. +const MAX_MODULE_IDENTIFIER_LEN: usize = 256; + +/// Validates a user module identifier (name or namespace). +/// +/// Rules: +/// - Must not be empty (after trimming whitespace) +/// - Must not contain `':'` +/// - Must not contain control characters +/// - Must not exceed [`MAX_MODULE_IDENTIFIER_LEN`] characters +/// +/// The `label` parameter is used in error messages (e.g. "Module name", "Module namespace"). +/// +/// Validation is intentionally duplicated here and in the inner Rust layer (`JSSandbox`) +/// for defense-in-depth. The NAPI layer validates first so that consumers get correct +/// `ERR_INVALID_ARG` error codes rather than the generic `ERR_INTERNAL` that would result +/// from the inner layer's `new_error!()`. +fn validate_module_identifier(value: &str, label: &str) -> napi::Result<()> { + if value.is_empty() || value.trim().is_empty() { + return Err(invalid_arg_error(&format!("{label} must not be empty"))); + } + if value.contains(':') { + return Err(invalid_arg_error(&format!("{label} must not contain ':'"))); + } + if value.chars().any(|c| c.is_control()) { + return Err(invalid_arg_error(&format!( + "{label} must not contain control characters" + ))); + } + if value.len() > MAX_MODULE_IDENTIFIER_LEN { + return Err(invalid_arg_error(&format!( + "{label} must not exceed {MAX_MODULE_IDENTIFIER_LEN} characters" + ))); + } + Ok(()) +} + +/// Validates that a namespace is not reserved (e.g. `"host"`). +fn validate_namespace_not_reserved(namespace: &str) -> napi::Result<()> { + // Keep in sync with RESERVED_NAMESPACES in js_sandbox.rs + if namespace == "host" { + return Err(invalid_arg_error(&format!( + "Module namespace '{namespace}' is reserved" + ))); + } + Ok(()) +} + /// Creates an error when a Mutex is poisoned (Rust-level, not sandbox-level). fn lock_error() -> napi::Error { hl_error( @@ -761,6 +810,88 @@ impl JSSandboxWrapper { }) } + // ── Module registration ────────────────────────────────────────── + + /// Register a named user module in the sandbox. + /// + /// The module source must use ES module syntax (`export`). Once registered, + /// handlers (and other modules) can import it using the qualified name + /// `:`. + /// + /// If `namespace` is omitted, the default namespace `"user"` is used, + /// making the module importable as `import { ... } from 'user:'`. + /// + /// Modules are compiled lazily when first imported, so inter-module + /// dependencies are resolved automatically regardless of registration order. + /// + /// This is a synchronous operation (module registration is cheap). + /// + /// @param moduleName - Module identifier (must be non-empty, must not contain ':') + /// @param source - ES module JavaScript source (must use `export`) + /// @param namespace - Optional namespace prefix (defaults to `"user"`) + /// @throws If the name is empty, namespace is reserved, or if sandbox is consumed + #[napi] + pub fn add_module( + &self, + module_name: String, + source: String, + namespace: Option, + ) -> napi::Result<()> { + validate_module_identifier(&module_name, "Module name")?; + if let Some(ref ns) = namespace { + validate_module_identifier(ns, "Module namespace")?; + validate_namespace_not_reserved(ns)?; + } + self.with_inner_mut(|sandbox| { + match namespace { + Some(ns) => sandbox.add_module_ns(module_name, Script::from_content(source), ns), + None => sandbox.add_module(module_name, Script::from_content(source)), + } + .map_err(to_napi_error) + }) + } + + /// Remove a previously registered module by name. + /// + /// If `namespace` is omitted, the default namespace `"user"` is used. + /// + /// This is a synchronous operation. + /// + /// @param moduleName - Module identifier to remove (must be non-empty) + /// @param namespace - Optional namespace prefix (defaults to `"user"`) + /// @throws If the module name is empty, or if the sandbox is consumed + #[napi] + pub fn remove_module( + &self, + module_name: String, + namespace: Option, + ) -> napi::Result<()> { + validate_module_identifier(&module_name, "Module name")?; + if let Some(ref ns) = namespace { + validate_module_identifier(ns, "Module namespace")?; + } + self.with_inner_mut(|sandbox| { + match namespace { + Some(ns) => sandbox.remove_module_ns(&module_name, &ns), + None => sandbox.remove_module(&module_name), + } + .map_err(to_napi_error) + }) + } + + /// Remove all registered modules. + /// + /// This is a synchronous operation. + /// + /// @throws If the sandbox is consumed + #[napi] + pub fn clear_modules(&self) -> napi::Result<()> { + self.with_inner_mut(|sandbox| { + sandbox.clear_modules(); + Ok(()) + }) + } + /// Transition to an execution-ready `LoadedJSSandbox`. /// /// All registered handlers are compiled and loaded into the guest. diff --git a/src/js-host-api/tests/user-modules.test.js b/src/js-host-api/tests/user-modules.test.js new file mode 100644 index 0000000..dda72cf --- /dev/null +++ b/src/js-host-api/tests/user-modules.test.js @@ -0,0 +1,631 @@ +// User module registration and import tests +// +// Tests the `addModule()` / `removeModule()` / `clearModules()` NAPI API +// for registering ES modules that handlers (and other modules) can import +// using the `:` convention. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SandboxBuilder } from '../lib.js'; +import { expectThrowsWithCode, expectRejectsWithCode } from './test-helpers.js'; + +// ── Helpers ────────────────────────────────────────────────────────── + +/** + * Create a JSSandbox (runtime loaded, ready for handlers/modules). + * @returns {Promise} + */ +async function createSandbox() { + const proto = await new SandboxBuilder().build(); + return proto.loadRuntime(); +} + +// ── Module registration ────────────────────────────────────────────── + +describe('Module registration', () => { + let sandbox; + + beforeEach(async () => { + sandbox = await createSandbox(); + }); + + it('should add a module without throwing', () => { + sandbox.addModule('utils', 'export function greet() { return "hi"; }'); + }); + + it('should add a module with explicit namespace', () => { + sandbox.addModule('utils', 'export function greet() { return "hi"; }', 'mylib'); + }); + + it('should remove a module without throwing', () => { + sandbox.addModule('utils', 'export const x = 1;'); + sandbox.removeModule('utils'); + }); + + it('should remove a module with explicit namespace', () => { + sandbox.addModule('utils', 'export const x = 1;', 'mylib'); + sandbox.removeModule('utils', 'mylib'); + }); + + it('should clear all modules without throwing', () => { + sandbox.addModule('a', 'export const x = 1;'); + sandbox.addModule('b', 'export const y = 2;'); + sandbox.clearModules(); + }); + + // ── Validation ─────────────────────────────────────────────────── + + it('should reject empty module name on add', () => { + expectThrowsWithCode(() => sandbox.addModule('', 'export const x = 1;'), 'ERR_INVALID_ARG'); + }); + + it('should reject empty module name on remove', () => { + expectThrowsWithCode(() => sandbox.removeModule(''), 'ERR_INVALID_ARG'); + }); + + it('should reject reserved "host" namespace', () => { + expectThrowsWithCode( + () => sandbox.addModule('utils', 'export const x = 1;', 'host'), + 'ERR_INVALID_ARG' + ); + }); + + it('should reject duplicate module', () => { + sandbox.addModule('utils', 'export const x = 1;'); + expectThrowsWithCode( + () => sandbox.addModule('utils', 'export const y = 2;'), + 'ERR_INTERNAL' // duplicate check is in the inner Rust layer + ); + }); + + it('should throw CONSUMED after getLoadedSandbox()', async () => { + sandbox.addModule('utils', 'export const x = 1;'); + sandbox.addHandler( + 'handler', + ` + import { x } from 'user:utils'; + function handler(e) { e.x = x; return e; } + ` + ); + await sandbox.getLoadedSandbox(); + expectThrowsWithCode( + () => sandbox.addModule('another', 'export const y = 2;'), + 'ERR_CONSUMED' + ); + }); +}); + +// ── Handler importing a user module ────────────────────────────────── + +describe('Handler importing user module', () => { + it('should import module with default namespace', async () => { + const sandbox = await createSandbox(); + sandbox.addModule( + 'math', + ` + export function add(a, b) { return a + b; } + export function multiply(a, b) { return a * b; } + ` + ); + sandbox.addHandler( + 'handler', + ` + import { add, multiply } from 'user:math'; + function handler(event) { + event.sum = add(event.a, event.b); + event.product = multiply(event.a, event.b); + return event; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const result = await loaded.callHandler('handler', { a: 6, b: 7 }); + expect(result.sum).toBe(13); + expect(result.product).toBe(42); + }); + + it('should import module with custom namespace', async () => { + const sandbox = await createSandbox(); + sandbox.addModule('math', 'export function add(a, b) { return a + b; }', 'mylib'); + sandbox.addHandler( + 'handler', + ` + import { add } from 'mylib:math'; + function handler(event) { + event.result = add(event.a, event.b); + return event; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const result = await loaded.callHandler('handler', { a: 10, b: 20 }); + expect(result.result).toBe(30); + }); +}); + +// ── Module importing another module ────────────────────────────────── + +describe('Inter-module imports', () => { + it('should allow a user module to import another user module', async () => { + const sandbox = await createSandbox(); + + // constants is imported by geometry — registration order doesn't matter + sandbox.addModule( + 'geometry', + ` + import { PI } from 'user:constants'; + export function circleArea(r) { return PI * r * r; } + ` + ); + sandbox.addModule( + 'constants', + ` + export const PI = 3.14159; + ` + ); + + sandbox.addHandler( + 'handler', + ` + import { circleArea } from 'user:geometry'; + function handler(event) { + event.area = circleArea(event.radius); + return event; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const result = await loaded.callHandler('handler', { radius: 5 }); + expect(result.area).toBeCloseTo(78.53975, 3); + }); + + it('should allow a user module to import a built-in module', async () => { + const sandbox = await createSandbox(); + + sandbox.addModule( + 'hasher', + ` + import { createHmac } from 'crypto'; + export function hmac(data) { + return createHmac('sha256', 'secret').update(data).digest('hex'); + } + ` + ); + + sandbox.addHandler( + 'handler', + ` + import { hmac } from 'user:hasher'; + function handler(event) { + event.hash = hmac(event.data); + return event; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const result = await loaded.callHandler('handler', { data: 'hello' }); + expect(result.hash).toBeTruthy(); + expect(typeof result.hash).toBe('string'); + expect(result.hash.length).toBeGreaterThan(0); + }); +}); + +// ── Module importing host functions ────────────────────────────────── + +describe('User module importing host functions', () => { + it('should allow a user module to call a host function', async () => { + const proto = await new SandboxBuilder().build(); + proto.hostModule('db').register('lookup', (id) => ({ id, name: `User ${id}` })); + + const sandbox = await proto.loadRuntime(); + + sandbox.addModule( + 'enricher', + ` + import * as db from 'host:db'; + export function enrich(event) { + const user = db.lookup(event.userId); + event.userName = user.name; + return event; + } + ` + ); + + sandbox.addHandler( + 'handler', + ` + import { enrich } from 'user:enricher'; + function handler(event) { + return enrich(event); + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const result = await loaded.callHandler('handler', { userId: 42 }); + expect(result.userName).toBe('User 42'); + }); +}); + +// ── State retention ────────────────────────────────────────────────── + +describe('Module state retention', () => { + it('should retain module state between handler calls', async () => { + const sandbox = await createSandbox(); + sandbox.addModule( + 'counter', + ` + let count = 0; + export function increment() { return ++count; } + ` + ); + sandbox.addHandler( + 'handler', + ` + import { increment } from 'user:counter'; + function handler(event) { + event.count = increment(); + return event; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const r1 = await loaded.callHandler('handler', {}); + expect(r1.count).toBe(1); + const r2 = await loaded.callHandler('handler', {}); + expect(r2.count).toBe(2); + }); +}); + +// ── Multiple handlers sharing a module ─────────────────────────────── + +describe('Multiple handlers sharing a module', () => { + it('should allow two handlers to import the same module', async () => { + const sandbox = await createSandbox(); + sandbox.addModule( + 'utils', + ` + export function double(x) { return x * 2; } + export function triple(x) { return x * 3; } + ` + ); + sandbox.addHandler( + 'doubler', + ` + import { double } from 'user:utils'; + function handler(event) { event.result = double(event.x); return event; } + ` + ); + sandbox.addHandler( + 'tripler', + ` + import { triple } from 'user:utils'; + function handler(event) { event.result = triple(event.x); return event; } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const r1 = await loaded.callHandler('doubler', { x: 5 }); + expect(r1.result).toBe(10); + const r2 = await loaded.callHandler('tripler', { x: 5 }); + expect(r2.result).toBe(15); + }); +}); + +// ── Cross-handler mutable state sharing ────────────────────────────── + +describe('Cross-handler mutable state sharing', () => { + it('should allow handler B to see mutable state written by handler A', async () => { + const sandbox = await createSandbox(); + + // Shared module with mutable state: a simple counter. + sandbox.addModule( + 'counter', + ` + let count = 0; + export function increment() { return ++count; } + export function getCount() { return count; } + ` + ); + + // Handler A: mutates state by calling increment() + sandbox.addHandler( + 'writer', + ` + import { increment } from 'user:counter'; + function handler(event) { + event.count = increment(); + return event; + } + ` + ); + + // Handler B: reads state without mutating it + sandbox.addHandler( + 'reader', + ` + import { getCount } from 'user:counter'; + function handler(event) { + event.count = getCount(); + return event; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + + // writer increments → count=1 + const r1 = await loaded.callHandler('writer', {}); + expect(r1.count).toBe(1); + + // reader sees the mutation made by writer → count=1 + const r2 = await loaded.callHandler('reader', {}); + expect(r2.count).toBe(1); + + // writer increments again → count=2 + const r3 = await loaded.callHandler('writer', {}); + expect(r3.count).toBe(2); + + // reader sees the updated state → count=2 + const r4 = await loaded.callHandler('reader', {}); + expect(r4.count).toBe(2); + }); + + it('should share complex mutable state between multiple handlers', async () => { + const sandbox = await createSandbox(); + + // Shared module with a richer state store (key-value map). + sandbox.addModule( + 'store', + ` + const data = new Map(); + export function set(key, value) { data.set(key, value); } + export function get(key) { return data.get(key); } + export function size() { return data.size; } + ` + ); + + // Handler that writes to the store + sandbox.addHandler( + 'put', + ` + import { set } from 'user:store'; + function handler(event) { + set(event.key, event.value); + return { ok: true }; + } + ` + ); + + // Handler that reads from the store + sandbox.addHandler( + 'fetch', + ` + import { get, size } from 'user:store'; + function handler(event) { + return { value: get(event.key), size: size() }; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + + // Write two entries via the 'put' handler + await loaded.callHandler('put', { key: 'name', value: 'Hyperlight' }); + await loaded.callHandler('put', { key: 'year', value: 1985 }); + + // Read them back via the 'fetch' handler + const r1 = await loaded.callHandler('fetch', { key: 'name' }); + expect(r1.value).toBe('Hyperlight'); + expect(r1.size).toBe(2); + + const r2 = await loaded.callHandler('fetch', { key: 'year' }); + expect(r2.value).toBe(1985); + }); +}); + +// ── Unload / reload cycle ──────────────────────────────────────────── + +describe('Unload and reload with modules', () => { + it('should allow swapping module versions after unload', async () => { + const sandbox = await createSandbox(); + sandbox.addModule('math', 'export function compute(x) { return x + 1; }'); + sandbox.addHandler( + 'handler', + ` + import { compute } from 'user:math'; + function handler(event) { event.result = compute(event.x); return event; } + ` + ); + + let loaded = await sandbox.getLoadedSandbox(); + let result = await loaded.callHandler('handler', { x: 5 }); + expect(result.result).toBe(6); // 5 + 1 + + // Unload, swap module, reload + const sandbox2 = await loaded.unload(); + sandbox2.addModule('math', 'export function compute(x) { return x * 2; }'); + sandbox2.addHandler( + 'handler', + ` + import { compute } from 'user:math'; + function handler(event) { event.result = compute(event.x); return event; } + ` + ); + + loaded = await sandbox2.getLoadedSandbox(); + result = await loaded.callHandler('handler', { x: 5 }); + expect(result.result).toBe(10); // 5 * 2 + }); +}); + +// ── Error on missing module import ─────────────────────────────────── + +describe('Missing module import', () => { + it('should fail at getLoadedSandbox when handler imports non-existent module', async () => { + const sandbox = await createSandbox(); + sandbox.addHandler( + 'handler', + ` + import { foo } from 'user:nonexistent'; + function handler(event) { return event; } + ` + ); + + // Loading should fail because the module doesn't exist + await expectRejectsWithCode(sandbox.getLoadedSandbox(), 'ERR_INTERNAL'); + }); +}); + +// ── clearModules behaviour verification ────────────────────────────── + +describe('clearModules behaviour', () => { + it('should make cleared modules unavailable on next load', async () => { + const sandbox = await createSandbox(); + sandbox.addModule('utils', 'export function greet() { return "hi"; }'); + sandbox.addHandler( + 'handler', + ` + import { greet } from 'user:utils'; + function handler(event) { event.msg = greet(); return event; } + ` + ); + + // Clear the module before loading + sandbox.clearModules(); + + // Loading should fail — handler imports a now-missing module + await expectRejectsWithCode(sandbox.getLoadedSandbox(), 'ERR_INTERNAL'); + }); +}); + +// ── Remove → load lifecycle ────────────────────────────────────────── + +describe('Remove then load lifecycle', () => { + it('should fail when handler imports a removed module', async () => { + const sandbox = await createSandbox(); + sandbox.addModule('utils', 'export function greet() { return "hi"; }'); + sandbox.removeModule('utils'); + sandbox.addHandler( + 'handler', + ` + import { greet } from 'user:utils'; + function handler(event) { event.msg = greet(); return event; } + ` + ); + + await expectRejectsWithCode(sandbox.getLoadedSandbox(), 'ERR_INTERNAL'); + }); +}); + +// ── Snapshot / restore with modules ────────────────────────────────── + +describe('Snapshot and restore with modules', () => { + it('should restore module state from snapshot', async () => { + const sandbox = await createSandbox(); + sandbox.addModule( + 'counter', + ` + let count = 0; + export function increment() { return ++count; } + ` + ); + sandbox.addHandler( + 'handler', + ` + import { increment } from 'user:counter'; + function handler(event) { event.count = increment(); return event; } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const r1 = await loaded.callHandler('handler', {}); + expect(r1.count).toBe(1); + + const snapshot = await loaded.snapshot(); + + const r2 = await loaded.callHandler('handler', {}); + expect(r2.count).toBe(2); + + await loaded.restore(snapshot); + + // After restore, counter should be back to snapshot state (count=1) + const r3 = await loaded.callHandler('handler', {}); + expect(r3.count).toBe(2); // post-snapshot call → 2 again + }); +}); + +// ── Circular module dependencies ───────────────────────────────────── + +describe('Circular module dependencies', () => { + it('should handle circular imports between two modules', async () => { + const sandbox = await createSandbox(); + + // Module A imports B, module B imports A — ESM circular imports + // are well-defined: live bindings resolve after evaluation + sandbox.addModule( + 'moduleA', + ` + import { getY } from 'user:moduleB'; + export function getX() { return 'X'; } + export function getXY() { return getX() + getY(); } + ` + ); + sandbox.addModule( + 'moduleB', + ` + import { getX } from 'user:moduleA'; + export function getY() { return 'Y'; } + export function getYX() { return getY() + getX(); } + ` + ); + + sandbox.addHandler( + 'handler', + ` + import { getXY } from 'user:moduleA'; + import { getYX } from 'user:moduleB'; + function handler(event) { + return { xy: getXY(), yx: getYX() }; + } + ` + ); + + const loaded = await sandbox.getLoadedSandbox(); + const result = await loaded.callHandler('handler', {}); + expect(result.xy).toBe('XY'); + expect(result.yx).toBe('YX'); + }); +}); + +// ── Additional validation ──────────────────────────────────────────── + +describe('Additional validation', () => { + it('should reject colon in module name', async () => { + const sandbox = await createSandbox(); + expectThrowsWithCode( + () => sandbox.addModule('bad:name', 'export const x = 1;'), + 'ERR_INVALID_ARG' + ); + }); + + it('should reject control characters in module name', async () => { + const sandbox = await createSandbox(); + expectThrowsWithCode( + () => sandbox.addModule('bad\nname', 'export const x = 1;'), + 'ERR_INVALID_ARG' + ); + }); + + it('should reject whitespace-only module name', async () => { + const sandbox = await createSandbox(); + expectThrowsWithCode( + () => sandbox.addModule(' ', 'export const x = 1;'), + 'ERR_INVALID_ARG' + ); + }); +});