Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ test-monitors target=default-target:
test-js-host-api target=default-target features="": (build-js-host-api target features)
cd src/js-host-api && npm test

# Run js-host-api examples (simple.js, calculator.js, unload.js, interrupt.js, cpu-timeout.js)
# Run js-host-api examples (simple.js, calculator.js, unload.js, interrupt.js, cpu-timeout.js, host-functions.js)
run-js-host-api-examples target=default-target features="": (build-js-host-api target features)
@echo "Running js-host-api examples..."
@echo ""
Expand All @@ -174,6 +174,8 @@ run-js-host-api-examples target=default-target features="": (build-js-host-api t
@echo ""
cd src/js-host-api && node examples/cpu-timeout.js
@echo ""
cd src/js-host-api && node examples/host-functions.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)
Expand Down
21 changes: 21 additions & 0 deletions src/hyperlight-js/src/sandbox/host_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,27 @@ impl HostModule {
self
}

/// Register a raw host function that operates on JSON strings directly.
///
/// Unlike [`register`](Self::register), which handles serde serialization /
/// deserialization automatically via the [`Function`] trait, this method
/// passes the raw JSON string argument from the guest to the closure and
/// expects a JSON string result.
///
/// This is primarily intended for dynamic / bridge scenarios (e.g. NAPI
/// bindings) where argument types are not known at compile time.
///
/// Registering a function with the same `name` as an existing function
/// overwrites the previous registration.
pub fn register_raw(
&mut self,
name: impl Into<String>,
func: impl Fn(String) -> crate::Result<String> + Send + Sync + 'static,
) -> &mut Self {
self.functions.insert(name.into(), Box::new(func));
self
}

pub(crate) fn get(&self, name: &str) -> Option<&BoxFunction> {
self.functions.get(name)
}
Expand Down
21 changes: 21 additions & 0 deletions src/hyperlight-js/src/sandbox/proto_js_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,27 @@ impl ProtoJSSandbox {
self.host_module(module).register(name, func);
Ok(())
}

/// Register a raw host function that operates on JSON strings directly.
///
/// This is equivalent to calling `sbox.host_module(module).register_raw(name, func)`.
///
/// Unlike [`register`](Self::register), which handles serde serialization /
/// deserialization automatically, this method passes the raw JSON string
/// from the guest to the callback and expects a JSON string result.
///
/// Primarily intended for dynamic / bridge scenarios (e.g. NAPI bindings)
/// where argument types are not known at compile time.
#[instrument(err(Debug), skip(self, func), level=Level::INFO)]
pub fn register_raw(
&mut self,
module: impl Into<String> + Debug,
name: impl Into<String> + Debug,
func: impl Fn(String) -> Result<String> + Send + Sync + 'static,
) -> Result<()> {
self.host_module(module).register_raw(name, func);
Ok(())
}
}

impl std::fmt::Debug for ProtoJSSandbox {
Expand Down
148 changes: 147 additions & 1 deletion src/hyperlight-js/tests/host_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ limitations under the License.

#![allow(clippy::disallowed_macros)]

use hyperlight_js::{SandboxBuilder, Script};
use hyperlight_js::{new_error, SandboxBuilder, Script};

#[test]
fn can_call_host_functions() {
Expand Down Expand Up @@ -213,3 +213,149 @@ fn host_fn_with_unusual_names() {

assert!(res == "42");
}

#[test]
fn register_raw_basic() {
let handler = Script::from_content(
r#"
import * as math from "math";
function handler(event) {
return { result: math.add(10, 32) };
}
"#,
);

let event = r#"{}"#;

let mut proto_js_sandbox = SandboxBuilder::new().build().unwrap();

// register_raw receives the guest args as a JSON string "[10,32]"
// and must return a JSON string result.
proto_js_sandbox
.register_raw("math", "add", |args: String| {
let parsed: Vec<i64> = serde_json::from_str(&args)?;
let sum: i64 = parsed.iter().sum();
Ok(serde_json::to_string(&sum)?)
})
.unwrap();

let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded_sandbox = sandbox.get_loaded_sandbox().unwrap();

let res = loaded_sandbox
.handle_event("handler", event.to_string(), None)
.unwrap();

assert_eq!(res, r#"{"result":42}"#);
}

#[test]
fn register_raw_mixed_with_typed() {
let handler = Script::from_content(
r#"
import * as math from "math";
function handler(event) {
let sum = math.add(10, 32);
let doubled = math.double(sum);
return { result: doubled };
}
"#,
);

let event = r#"{}"#;

let mut proto_js_sandbox = SandboxBuilder::new().build().unwrap();

// Typed registration via the Function trait
proto_js_sandbox
.register("math", "add", |a: i32, b: i32| a + b)
.unwrap();

// Raw registration alongside typed — both in the same module
proto_js_sandbox
.register_raw("math", "double", |args: String| {
let parsed: Vec<i64> = serde_json::from_str(&args)?;
let val = parsed.first().copied().unwrap_or(0);
Ok(serde_json::to_string(&(val * 2))?)
})
.unwrap();

let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded_sandbox = sandbox.get_loaded_sandbox().unwrap();

let res = loaded_sandbox
.handle_event("handler", event.to_string(), None)
.unwrap();

assert_eq!(res, r#"{"result":84}"#);
}

#[test]
fn register_raw_error_propagation() {
let handler = Script::from_content(
r#"
import * as host from "host";
function handler(event) {
return host.fail();
}
"#,
);

let event = r#"{}"#;

let mut proto_js_sandbox = SandboxBuilder::new().build().unwrap();

proto_js_sandbox
.register_raw("host", "fail", |_args: String| {
Err(new_error!("intentional failure from raw host fn"))
})
.unwrap();

let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded_sandbox = sandbox.get_loaded_sandbox().unwrap();

let err = loaded_sandbox
.handle_event("handler", event.to_string(), None)
.unwrap_err();

assert!(err.to_string().contains("intentional failure"));
}

#[test]
fn register_raw_via_host_module() {
let handler = Script::from_content(
r#"
import * as utils from "utils";
function handler(event) {
let greeting = utils.greet("World");
return { greeting };
}
"#,
);

let event = r#"{}"#;

let mut proto_js_sandbox = SandboxBuilder::new().build().unwrap();

// Use host_module() accessor + register_raw() directly on HostModule
proto_js_sandbox
.host_module("utils")
.register_raw("greet", |args: String| {
let parsed: Vec<String> = serde_json::from_str(&args)?;
let name = parsed.first().cloned().unwrap_or_default();
Ok(serde_json::to_string(&format!("Hello, {}!", name))?)
});

let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
sandbox.add_handler("handler", handler).unwrap();
let mut loaded_sandbox = sandbox.get_loaded_sandbox().unwrap();

let res = loaded_sandbox
.handle_event("handler", event.to_string(), None)
.unwrap();

assert_eq!(res, r#"{"greeting":"Hello, World!"}"#);
}
2 changes: 1 addition & 1 deletion src/js-host-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ crate-type = ["cdylib"]

[dependencies]
hyperlight-js = { workspace = true, features = ["monitor-wall-clock", "monitor-cpu-time"] }
napi = { version = "3.8", features = ["async", "serde-json"] }
napi = { version = "3.8", features = ["tokio_rt", "serde-json"] }
napi-derive = "3.5"
serde_json = "1"
tokio = { version = "1", features = ["rt"] }
Expand Down
Loading