Skip to content

Commit 754139c

Browse files
authored
feat(js-host-api): add host function registration for Node.js (#31)
Add support for registering host-side JavaScript callbacks that guest sandboxed code can call via ES module imports. New APIs: - proto.hostModule(name) - Create a builder for registering functions - builder.register(name, callback) - Register a host function - proto.register(module, fn, callback) - Convenience method Guest code imports host modules as 'import * as math from "host:math"' and calls functions like math.add(1, 2). Arguments are JSON-serialized across the sandbox boundary. Both sync and async callbacks are supported. Changes: - hyperlight-js: Add register_raw() for dynamic NAPI scenarios - js-host-api: Add HostModule wrapper, ThreadsafeFunction bridge - js-host-api: Update Cargo.toml to use tokio_rt feature - Add comprehensive tests (22 new JS tests, 4 new Rust tests) - Add host-functions.js example - Update README with Host Functions documentation Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 7034130 commit 754139c

File tree

10 files changed

+1384
-15
lines changed

10 files changed

+1384
-15
lines changed

Justfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ test-monitors target=default-target:
160160
test-js-host-api target=default-target features="": (build-js-host-api target features)
161161
cd src/js-host-api && npm test
162162

163-
# Run js-host-api examples (simple.js, calculator.js, unload.js, interrupt.js, cpu-timeout.js)
163+
# Run js-host-api examples (simple.js, calculator.js, unload.js, interrupt.js, cpu-timeout.js, host-functions.js)
164164
run-js-host-api-examples target=default-target features="": (build-js-host-api target features)
165165
@echo "Running js-host-api examples..."
166166
@echo ""
@@ -174,6 +174,8 @@ run-js-host-api-examples target=default-target features="": (build-js-host-api t
174174
@echo ""
175175
cd src/js-host-api && node examples/cpu-timeout.js
176176
@echo ""
177+
cd src/js-host-api && node examples/host-functions.js
178+
@echo ""
177179
@echo "✅ All examples completed successfully!"
178180

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

src/hyperlight-js/src/sandbox/host_fn.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ impl HostModule {
9292
self
9393
}
9494

95+
/// Register a raw host function that operates on JSON strings directly.
96+
///
97+
/// Unlike [`register`](Self::register), which handles serde serialization /
98+
/// deserialization automatically via the [`Function`] trait, this method
99+
/// passes the raw JSON string argument from the guest to the closure and
100+
/// expects a JSON string result.
101+
///
102+
/// This is primarily intended for dynamic / bridge scenarios (e.g. NAPI
103+
/// bindings) where argument types are not known at compile time.
104+
///
105+
/// Registering a function with the same `name` as an existing function
106+
/// overwrites the previous registration.
107+
pub fn register_raw(
108+
&mut self,
109+
name: impl Into<String>,
110+
func: impl Fn(String) -> crate::Result<String> + Send + Sync + 'static,
111+
) -> &mut Self {
112+
self.functions.insert(name.into(), Box::new(func));
113+
self
114+
}
115+
95116
pub(crate) fn get(&self, name: &str) -> Option<&BoxFunction> {
96117
self.functions.get(name)
97118
}

src/hyperlight-js/src/sandbox/proto_js_sandbox.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,27 @@ impl ProtoJSSandbox {
212212
self.host_module(module).register(name, func);
213213
Ok(())
214214
}
215+
216+
/// Register a raw host function that operates on JSON strings directly.
217+
///
218+
/// This is equivalent to calling `sbox.host_module(module).register_raw(name, func)`.
219+
///
220+
/// Unlike [`register`](Self::register), which handles serde serialization /
221+
/// deserialization automatically, this method passes the raw JSON string
222+
/// from the guest to the callback and expects a JSON string result.
223+
///
224+
/// Primarily intended for dynamic / bridge scenarios (e.g. NAPI bindings)
225+
/// where argument types are not known at compile time.
226+
#[instrument(err(Debug), skip(self, func), level=Level::INFO)]
227+
pub fn register_raw(
228+
&mut self,
229+
module: impl Into<String> + Debug,
230+
name: impl Into<String> + Debug,
231+
func: impl Fn(String) -> Result<String> + Send + Sync + 'static,
232+
) -> Result<()> {
233+
self.host_module(module).register_raw(name, func);
234+
Ok(())
235+
}
215236
}
216237

217238
impl std::fmt::Debug for ProtoJSSandbox {

src/hyperlight-js/tests/host_functions.rs

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ limitations under the License.
1717
1818
#![allow(clippy::disallowed_macros)]
1919

20-
use hyperlight_js::{SandboxBuilder, Script};
20+
use hyperlight_js::{new_error, SandboxBuilder, Script};
2121

2222
#[test]
2323
fn can_call_host_functions() {
@@ -213,3 +213,149 @@ fn host_fn_with_unusual_names() {
213213

214214
assert!(res == "42");
215215
}
216+
217+
#[test]
218+
fn register_raw_basic() {
219+
let handler = Script::from_content(
220+
r#"
221+
import * as math from "math";
222+
function handler(event) {
223+
return { result: math.add(10, 32) };
224+
}
225+
"#,
226+
);
227+
228+
let event = r#"{}"#;
229+
230+
let mut proto_js_sandbox = SandboxBuilder::new().build().unwrap();
231+
232+
// register_raw receives the guest args as a JSON string "[10,32]"
233+
// and must return a JSON string result.
234+
proto_js_sandbox
235+
.register_raw("math", "add", |args: String| {
236+
let parsed: Vec<i64> = serde_json::from_str(&args)?;
237+
let sum: i64 = parsed.iter().sum();
238+
Ok(serde_json::to_string(&sum)?)
239+
})
240+
.unwrap();
241+
242+
let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
243+
sandbox.add_handler("handler", handler).unwrap();
244+
let mut loaded_sandbox = sandbox.get_loaded_sandbox().unwrap();
245+
246+
let res = loaded_sandbox
247+
.handle_event("handler", event.to_string(), None)
248+
.unwrap();
249+
250+
assert_eq!(res, r#"{"result":42}"#);
251+
}
252+
253+
#[test]
254+
fn register_raw_mixed_with_typed() {
255+
let handler = Script::from_content(
256+
r#"
257+
import * as math from "math";
258+
function handler(event) {
259+
let sum = math.add(10, 32);
260+
let doubled = math.double(sum);
261+
return { result: doubled };
262+
}
263+
"#,
264+
);
265+
266+
let event = r#"{}"#;
267+
268+
let mut proto_js_sandbox = SandboxBuilder::new().build().unwrap();
269+
270+
// Typed registration via the Function trait
271+
proto_js_sandbox
272+
.register("math", "add", |a: i32, b: i32| a + b)
273+
.unwrap();
274+
275+
// Raw registration alongside typed — both in the same module
276+
proto_js_sandbox
277+
.register_raw("math", "double", |args: String| {
278+
let parsed: Vec<i64> = serde_json::from_str(&args)?;
279+
let val = parsed.first().copied().unwrap_or(0);
280+
Ok(serde_json::to_string(&(val * 2))?)
281+
})
282+
.unwrap();
283+
284+
let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
285+
sandbox.add_handler("handler", handler).unwrap();
286+
let mut loaded_sandbox = sandbox.get_loaded_sandbox().unwrap();
287+
288+
let res = loaded_sandbox
289+
.handle_event("handler", event.to_string(), None)
290+
.unwrap();
291+
292+
assert_eq!(res, r#"{"result":84}"#);
293+
}
294+
295+
#[test]
296+
fn register_raw_error_propagation() {
297+
let handler = Script::from_content(
298+
r#"
299+
import * as host from "host";
300+
function handler(event) {
301+
return host.fail();
302+
}
303+
"#,
304+
);
305+
306+
let event = r#"{}"#;
307+
308+
let mut proto_js_sandbox = SandboxBuilder::new().build().unwrap();
309+
310+
proto_js_sandbox
311+
.register_raw("host", "fail", |_args: String| {
312+
Err(new_error!("intentional failure from raw host fn"))
313+
})
314+
.unwrap();
315+
316+
let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
317+
sandbox.add_handler("handler", handler).unwrap();
318+
let mut loaded_sandbox = sandbox.get_loaded_sandbox().unwrap();
319+
320+
let err = loaded_sandbox
321+
.handle_event("handler", event.to_string(), None)
322+
.unwrap_err();
323+
324+
assert!(err.to_string().contains("intentional failure"));
325+
}
326+
327+
#[test]
328+
fn register_raw_via_host_module() {
329+
let handler = Script::from_content(
330+
r#"
331+
import * as utils from "utils";
332+
function handler(event) {
333+
let greeting = utils.greet("World");
334+
return { greeting };
335+
}
336+
"#,
337+
);
338+
339+
let event = r#"{}"#;
340+
341+
let mut proto_js_sandbox = SandboxBuilder::new().build().unwrap();
342+
343+
// Use host_module() accessor + register_raw() directly on HostModule
344+
proto_js_sandbox
345+
.host_module("utils")
346+
.register_raw("greet", |args: String| {
347+
let parsed: Vec<String> = serde_json::from_str(&args)?;
348+
let name = parsed.first().cloned().unwrap_or_default();
349+
Ok(serde_json::to_string(&format!("Hello, {}!", name))?)
350+
});
351+
352+
let mut sandbox = proto_js_sandbox.load_runtime().unwrap();
353+
sandbox.add_handler("handler", handler).unwrap();
354+
let mut loaded_sandbox = sandbox.get_loaded_sandbox().unwrap();
355+
356+
let res = loaded_sandbox
357+
.handle_event("handler", event.to_string(), None)
358+
.unwrap();
359+
360+
assert_eq!(res, r#"{"greeting":"Hello, World!"}"#);
361+
}

src/js-host-api/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ crate-type = ["cdylib"]
1313

1414
[dependencies]
1515
hyperlight-js = { workspace = true, features = ["monitor-wall-clock", "monitor-cpu-time"] }
16-
napi = { version = "3.8", features = ["async", "serde-json"] }
16+
napi = { version = "3.8", features = ["tokio_rt", "serde-json"] }
1717
napi-derive = "3.5"
1818
serde_json = "1"
1919
tokio = { version = "1", features = ["rt"] }

0 commit comments

Comments
 (0)