Skip to content

Commit 53858a0

Browse files
committed
feat: allow extending hyperlight-js-runtime with custom native modules
Resolves #48 Add a registration-based system for extending the JS runtime with custom native (Rust-implemented) modules that run inside the Hyperlight guest VM. Key changes: - hyperlight-js-runtime/Cargo.toml: Add [lib] target so the runtime can be used as a library dependency by extender crates. - modules/mod.rs: Add global CUSTOM_MODULES registry with register_native_module() and builtin_module_names(). NativeModuleLoader checks custom registry first, falls back to built-in modules. Panics if custom module name conflicts with a built-in. - native_modules! macro: Generates init_native_modules() (#[no_mangle]) that registers custom modules. Called automatically via spin::Once on first NativeModuleLoader access — no explicit init needed. - guest/mod.rs: Move hyperlight guest entry point (hyperlight_main, guest_dispatch_function, Host impl, stubs) from main/hyperlight.rs into the lib behind cfg(hyperlight). Extender binaries get all guest infrastructure for free by depending on the lib. - hyperlight-js/build.rs: Add HYPERLIGHT_JS_RUNTIME_PATH env var override to embed custom runtime binaries instead of the default. - host.rs: Restore original Host trait only (no FsHost extraction). Testing: - 13 unit/integration tests in hyperlight-js-runtime (loader, registry, macro, override prevention, E2E with native CLI fixture binary) - 3 Hyperlight VM integration tests in hyperlight-js (#[ignore], run via just test-native-modules) - Extended runtime fixture crate with shared native_math module - just test-native-modules recipe for full hyperlight pipeline Docs: - docs/extending-runtime.md with quick start, host-side usage, native testing, API reference, and architecture diagram
1 parent a892a0a commit 53858a0

File tree

21 files changed

+2183
-45
lines changed

21 files changed

+2183
-45
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[workspace]
22
resolver = "2"
33
members = ["src/hyperlight-js", "src/js-host-api", "src/hyperlight-js-runtime"]
4+
exclude = ["src/hyperlight-js-runtime/tests/fixtures/extended_runtime"]
45

56
[workspace.package]
67
version = "0.1.1"

Justfile

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ clean:
136136
cargo clean
137137
cd src/hyperlight-js-runtime && cargo clean
138138
cd src/js-host-api && cargo clean
139+
-rm -rf src/hyperlight-js-runtime/tests/fixtures/extended_runtime/target
140+
-rm -rf src/hyperlight-js-runtime/tests/fixtures/native_math/target
139141
-rm -rf src/js-host-api/node_modules
140142
-rm -f src/js-host-api/*.node
141143
-rm -f src/js-host-api/index.js
@@ -151,15 +153,52 @@ test target=default-target features="": (build target)
151153
cd src/hyperlight-js && cargo test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} handle_termination --profile={{ if target == "debug" {"dev"} else { target } }} -- --ignored --nocapture
152154
cd src/hyperlight-js && cargo test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} test_metrics --profile={{ if target == "debug" {"dev"} else { target } }} -- --ignored --nocapture
153155
cargo test --manifest-path=./src/hyperlight-js-runtime/Cargo.toml --test=native_cli --profile={{ if target == "debug" {"dev"} else { target } }}
156+
just test-native-modules {{ target }}
154157

155-
# Test with monitor features enabled (wall-clock, CPU time, and guest-call-stats)
156-
# Note: We exclude test_metrics as it requires process isolation and is already run by `test` recipe
158+
# Test with monitor features enabled (wall-clock and CPU time monitors)
159+
# Note: We exclude test_metrics (requires process isolation, already run by `test`)
160+
# and native_modules (requires custom guest runtime, run by `test-native-modules`)
157161
test-monitors target=default-target:
158-
cd src/hyperlight-js && cargo test --features monitor-wall-clock,monitor-cpu-time,guest-call-stats --profile={{ if target == "debug" {"dev"} else { target } }} -- --include-ignored --skip test_metrics
162+
cd src/hyperlight-js && cargo test --features monitor-wall-clock,monitor-cpu-time --profile={{ if target == "debug" {"dev"} else { target } }} -- --include-ignored --skip test_metrics --skip custom_native_module --skip builtin_modules_work_with_custom --skip console_log_works_with_custom
159163

160164
test-js-host-api target=default-target features="": (build-js-host-api target features)
161165
cd src/js-host-api && npm test
162166

167+
# Test custom native modules:
168+
# 1. Runs the runtime crate's native_modules unit/pipeline tests (native binary)
169+
# 2. Builds the extended_runtime fixture for the hyperlight target
170+
# 3. Rebuilds hyperlight-js with the custom guest embedded via HYPERLIGHT_JS_RUNTIME_PATH
171+
# 4. Runs the ignored VM integration tests
172+
# 5. Rebuilds hyperlight-js with the default guest (unsets HYPERLIGHT_JS_RUNTIME_PATH)
173+
#
174+
# The build.rs in hyperlight-js has `cargo:rerun-if-env-changed=HYPERLIGHT_JS_RUNTIME_PATH`
175+
# so setting/unsetting the env var triggers a rebuild automatically.
176+
177+
# Base path to the extended runtime fixture target directory
178+
extended_runtime_target := replace(justfile_dir(), "\\", "/") + "/src/hyperlight-js-runtime/tests/fixtures/extended_runtime/target/x86_64-hyperlight-none"
179+
180+
test-native-modules target=default-target: (ensure-tools) (_test-native-modules-unit target) (_test-native-modules-build-guest target) (_test-native-modules-vm target) (_test-native-modules-restore target)
181+
182+
[private]
183+
_test-native-modules-unit target=default-target:
184+
cargo test --manifest-path=./src/hyperlight-js-runtime/Cargo.toml --test=native_modules --profile={{ if target == "debug" {"dev"} else { target } }}
185+
186+
[private]
187+
_test-native-modules-build-guest target=default-target:
188+
cargo hyperlight build \
189+
--manifest-path src/hyperlight-js-runtime/tests/fixtures/extended_runtime/Cargo.toml \
190+
--profile={{ if target == "debug" {"dev"} else { target } }} \
191+
--target-dir src/hyperlight-js-runtime/tests/fixtures/extended_runtime/target
192+
193+
[private]
194+
_test-native-modules-vm target=default-target:
195+
{{ set-env-command }}HYPERLIGHT_JS_RUNTIME_PATH="{{extended_runtime_target}}/{{ if target == "debug" {"debug"} else { target } }}/extended-runtime" {{ if os() == "windows" { ";" } else { "&&" } }} cargo test -p hyperlight-js --test native_modules --profile={{ if target == "debug" {"dev"} else { target } }} -- --ignored --nocapture
196+
197+
[private]
198+
_test-native-modules-restore target=default-target:
199+
@echo "Rebuilding hyperlight-js with default guest runtime..."
200+
cd src/hyperlight-js && cargo build --profile={{ if target == "debug" {"dev"} else { target } }}
201+
163202
# Run js-host-api examples (simple.js, calculator.js, unload.js, interrupt.js, cpu-timeout.js, host-functions.js)
164203
run-js-host-api-examples target=default-target features="": (build-js-host-api target features)
165204
@echo "Running js-host-api examples..."

docs/extending-runtime.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# Extending the Runtime with Custom Native Modules
2+
3+
This document describes how to extend `hyperlight-js-runtime` with custom
4+
native (Rust-implemented) modules that run alongside the built-in modules
5+
inside the Hyperlight guest VM.
6+
7+
## Why Native Modules? 🤔
8+
9+
Some operations are too slow in pure JavaScript. For example, DEFLATE
10+
compression can be 50–100× slower than native Rust, which may trigger CPU
11+
timeouts on large inputs. Native modules let you add high-performance Rust
12+
code that JavaScript handlers can `import` — without forking the runtime.
13+
14+
## How It Works
15+
16+
1. **`hyperlight-js-runtime` as a library** — the runtime crate exposes a
17+
`[lib]` target so your crate can depend on it.
18+
2. **`native_modules!` macro** — registers custom modules into a global
19+
registry. The runtime's `NativeModuleLoader` checks custom modules
20+
first, then falls back to built-ins (io, crypto, console, require).
21+
3. **`HYPERLIGHT_JS_RUNTIME_PATH`** — a build-time env var that tells
22+
`hyperlight-js` to embed your custom runtime binary instead of the
23+
default one.
24+
25+
## Quick Start
26+
27+
### 1. Create your custom runtime crate
28+
29+
```bash
30+
cargo init --bin my-custom-runtime
31+
```
32+
33+
```toml
34+
[dependencies]
35+
hyperlight-js-runtime = { git = "https://github.com/hyperlight-dev/hyperlight-js" }
36+
rquickjs = { version = "0.11", default-features = false, features = ["bindgen", "futures", "macro", "loader"] }
37+
38+
# Only needed for native CLI testing, not the hyperlight guest
39+
[target.'cfg(not(hyperlight))'.dependencies]
40+
anyhow = "1.0"
41+
42+
[lints.rust]
43+
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(hyperlight)'] }
44+
```
45+
46+
> **Note:** The `rquickjs` version and features must match what
47+
> `hyperlight-js-runtime` uses. Check its `Cargo.toml` for the exact spec.
48+
49+
### 2. Define your module and register it
50+
51+
```rust
52+
#![cfg_attr(hyperlight, no_std)]
53+
#![cfg_attr(hyperlight, no_main)]
54+
55+
#[rquickjs::module(rename_vars = "camelCase")]
56+
mod math {
57+
#[rquickjs::function]
58+
pub fn add(a: f64, b: f64) -> f64 { a + b }
59+
60+
#[rquickjs::function]
61+
pub fn multiply(a: f64, b: f64) -> f64 { a * b }
62+
}
63+
64+
hyperlight_js_runtime::native_modules! {
65+
"math" => js_math,
66+
}
67+
```
68+
69+
That's all the Rust you write for the Hyperlight guest. The macro generates
70+
an `init_native_modules()` function that the `NativeModuleLoader` calls
71+
automatically on first use. Built-in modules are inherited. The lib provides
72+
all hyperlight guest infrastructure (entry point, host function dispatch,
73+
libc stubs) — no copying files or build scripts needed.
74+
75+
### 3. Build and embed in hyperlight-js
76+
77+
Build your custom runtime for the Hyperlight target and embed it:
78+
79+
```bash
80+
# Build for the hyperlight target
81+
cargo hyperlight build --manifest-path my-custom-runtime/Cargo.toml
82+
83+
# Build hyperlight-js with your custom runtime embedded
84+
HYPERLIGHT_JS_RUNTIME_PATH=/path/to/my-custom-runtime \
85+
cargo build -p hyperlight-js
86+
```
87+
88+
### 4. Use from the host
89+
90+
The host-side code is **identical** to any other `hyperlight-js` usage.
91+
Custom native modules are transparent — they're baked into the guest
92+
binary. Your handlers just `import` from them:
93+
94+
```rust
95+
use hyperlight_js::{SandboxBuilder, Script};
96+
97+
fn main() -> anyhow::Result<()> {
98+
let proto = SandboxBuilder::new().build()?;
99+
let mut sandbox = proto.load_runtime()?;
100+
101+
let handler = Script::from_content(r#"
102+
import { add, multiply } from "math";
103+
export function handler(event) {
104+
return {
105+
sum: add(event.a, event.b),
106+
product: multiply(event.a, event.b),
107+
};
108+
}
109+
"#);
110+
sandbox.add_handler("compute", handler)?;
111+
112+
let mut loaded = sandbox.get_loaded_sandbox()?;
113+
let result = loaded.handle_event("compute", r#"{"a":6,"b":7}"#.to_string(), None)?;
114+
115+
println!("{result}");
116+
// {"sum":13,"product":42}
117+
118+
Ok(())
119+
}
120+
```
121+
122+
### 5. Test natively (optional)
123+
124+
For local development you can run your custom runtime as a native CLI
125+
without building for Hyperlight. Add a `main()` to your `main.rs`.
126+
127+
Since your custom modules are registered via the macro (and built-ins are
128+
handled by the runtime), you don't need filesystem module resolution (But you can have it if you want it).
129+
A no-op `Host` is all that's needed — it only gets called for `.js` file
130+
imports, which native modules don't use:
131+
132+
```rust
133+
struct NoOpHost;
134+
impl hyperlight_js_runtime::host::Host for NoOpHost {
135+
fn resolve_module(&self, _base: String, name: String) -> anyhow::Result<String> {
136+
anyhow::bail!("Module '{name}' not found")
137+
}
138+
fn load_module(&self, name: String) -> anyhow::Result<String> {
139+
anyhow::bail!("Module '{name}' not found")
140+
}
141+
}
142+
143+
fn main() -> anyhow::Result<()> {
144+
let args: Vec<String> = std::env::args().collect();
145+
let script = std::fs::read_to_string(&args[1])?;
146+
147+
let mut runtime = hyperlight_js_runtime::JsRuntime::new(NoOpHost)?;
148+
runtime.register_handler("handler", script, ".")?;
149+
let result = runtime.run_handler("handler".into(), args[2].clone(), false)?;
150+
println!("{result}");
151+
Ok(())
152+
}
153+
```
154+
155+
```bash
156+
# handler.js
157+
cat > handler.js << 'EOF'
158+
import { add, multiply } from "math";
159+
export function handler(event) {
160+
return { sum: add(event.a, event.b), product: multiply(event.a, event.b) };
161+
}
162+
EOF
163+
164+
cargo run -- handler.js '{"a":6,"b":7}'
165+
# {"sum":13,"product":42}
166+
```
167+
168+
## Complete Example
169+
170+
See the [extended_runtime fixture](../src/hyperlight-js-runtime/tests/fixtures/extended_runtime/)
171+
for a working example with end-to-end tests.
172+
173+
Run `just test-native-modules` to build the fixture for the Hyperlight
174+
target and run the full integration tests.
175+
176+
## API Reference
177+
178+
### `native_modules!`
179+
180+
```rust
181+
hyperlight_js_runtime::native_modules! {
182+
"module_name" => ModuleDefType,
183+
"another" => AnotherModuleDefType,
184+
}
185+
```
186+
187+
Generates an `init_native_modules()` function that registers the listed
188+
modules into the global native module registry. Called automatically by the
189+
`NativeModuleLoader` on first use — you never need to call it yourself.
190+
Built-in modules are inherited automatically.
191+
192+
**Restrictions:**
193+
- Custom module names **cannot** shadow built-in modules (`io`, `crypto`,
194+
`console`, `require`). Attempting to register a built-in name panics.
195+
196+
### `register_native_module`
197+
198+
```rust
199+
hyperlight_js_runtime::modules::register_native_module(name, declaration_fn)
200+
```
201+
202+
Register a single custom native module by name. Typically called via the
203+
`native_modules!` macro rather than directly.
204+
205+
### `JsRuntime::new`
206+
207+
```rust
208+
hyperlight_js_runtime::JsRuntime::new(host)
209+
```

src/hyperlight-js-runtime/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ license.workspace = true
77
repository.workspace = true
88
readme.workspace = true
99
description = """
10-
hyperlight-js-runtime is a rust binary crate that provides the JavaScript runtime binary for hyperlight-js.
10+
hyperlight-js-runtime provides the JavaScript runtime for hyperlight-js, both as a library and binary.
1111
"""
1212

13+
[lib]
14+
name = "hyperlight_js_runtime"
15+
path = "src/lib.rs"
16+
1317
[[bin]]
1418
name = "hyperlight-js-runtime"
19+
path = "src/main.rs"
1520
harness = false
1621
test = false
1722

src/hyperlight-js-runtime/src/main/hyperlight.rs renamed to src/hyperlight-js-runtime/src/guest/mod.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,23 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
16-
extern crate alloc;
16+
17+
//! Hyperlight guest entry point and infrastructure.
18+
//!
19+
//! This module provides the guest-side plumbing needed to run the JS runtime
20+
//! inside a Hyperlight VM. It includes:
21+
//! - The `Host` implementation that calls out to hyperlight host functions
22+
//! - The `hyperlight_main` entry point
23+
//! - Guest function registrations (register_handler, RegisterHostModules)
24+
//! - The `guest_dispatch_function` fallback for handler calls
25+
//! - Libc stub implementations required by QuickJS
26+
//!
27+
//! This is all `cfg(hyperlight)` — compiled out entirely for native builds.
1728
1829
use alloc::format;
1930
use alloc::string::String;
2031
use alloc::vec::Vec;
32+
2133
use anyhow::{anyhow, Context as _};
2234
use hashbrown::HashMap;
2335
use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall;
@@ -33,7 +45,7 @@ mod stubs;
3345

3446
struct Host;
3547

36-
pub trait CatchGuestErrorExt {
48+
trait CatchGuestErrorExt {
3749
type Ok;
3850
fn catch(self) -> anyhow::Result<Self::Ok>;
3951
}
@@ -45,7 +57,7 @@ impl<T> CatchGuestErrorExt for hyperlight_guest::error::Result<T> {
4557
}
4658
}
4759

48-
impl hyperlight_js_runtime::host::Host for Host {
60+
impl crate::host::Host for Host {
4961
fn resolve_module(&self, base: String, name: String) -> anyhow::Result<String> {
5062
#[host_function("ResolveModule")]
5163
fn resolve_module(base: String, name: String) -> Result<String>;
@@ -65,17 +77,16 @@ impl hyperlight_js_runtime::host::Host for Host {
6577
}
6678
}
6779

68-
static RUNTIME: spin::Lazy<Mutex<hyperlight_js_runtime::JsRuntime>> = spin::Lazy::new(|| {
69-
Mutex::new(hyperlight_js_runtime::JsRuntime::new(Host).unwrap_or_else(|e| {
80+
static RUNTIME: spin::Lazy<Mutex<crate::JsRuntime>> = spin::Lazy::new(|| {
81+
Mutex::new(crate::JsRuntime::new(Host).unwrap_or_else(|e| {
7082
panic!("Failed to initialize JS runtime: {e:#?}");
7183
}))
7284
});
7385

7486
#[unsafe(no_mangle)]
7587
#[instrument(skip_all, level = "info")]
7688
pub extern "C" fn hyperlight_main() {
77-
// dereference RUNTIME to force its initialization
78-
// of the Lazy static
89+
// Initialise the runtime (custom modules are registered lazily on first use)
7990
let _ = &*RUNTIME;
8091
}
8192

File renamed without changes.
File renamed without changes.

src/hyperlight-js-runtime/src/main/stubs/localtime.rs renamed to src/hyperlight-js-runtime/src/guest/stubs/localtime.rs

File renamed without changes.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)