Skip to content

Commit 02e97e5

Browse files
committed
feat: add custom_globals! macro for registering global JS objects
Add custom_globals! macro alongside native_modules! to allow extender crates to register global JS objects (TextEncoder, TextDecoder, polyfills, constants) without modifying hyperlight-js-runtime. - New custom_globals! macro in modules/mod.rs (same #[no_mangle] + extern - setup_custom_globals() bridge called in JsRuntime::new() after built-in globals - Default empty custom_globals! {} in base runtime main.rs - Test fixture with globalThis.CUSTOM_GLOBAL_TEST = 42 - 6 new tests: unit e2e + full pipeline (standalone, coexist with builtins, combined with native modules) - Documentation in docs/extending-runtime.md
1 parent 3e43831 commit 02e97e5

File tree

6 files changed

+317
-1
lines changed

6 files changed

+317
-1
lines changed

docs/extending-runtime.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,90 @@ Register a single custom native module by name. Typically called via the
207207
```rust
208208
hyperlight_js_runtime::JsRuntime::new(host)
209209
```
210+
211+
## Custom Globals
212+
213+
Register global objects (constructors, polyfills, constants) available
214+
to all JavaScript code without `import`:
215+
216+
```rust
217+
fn setup_my_globals(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
218+
ctx.eval::<(), _>("globalThis.MY_CONSTANT = 42;")?;
219+
Ok(())
220+
}
221+
222+
hyperlight_js_runtime::custom_globals! {
223+
setup_my_globals,
224+
}
225+
```
226+
227+
Custom globals are set up after built-in globals (console, require, print)
228+
during `JsRuntime::new()`. Both Rust-implemented classes (via
229+
`#[rquickjs::class]`) and JavaScript polyfills (via `ctx.eval()`) are
230+
supported.
231+
232+
### Rust class example
233+
234+
For things like `TextEncoder` / `TextDecoder` where you need a proper
235+
constructor accessible as `new TextEncoder()`:
236+
237+
```rust
238+
use rquickjs::{Ctx, class::Trace, JsLifetime, TypedArray};
239+
240+
#[rquickjs::class]
241+
#[derive(Trace, JsLifetime)]
242+
pub struct TextEncoder {}
243+
244+
#[rquickjs::methods]
245+
impl TextEncoder {
246+
#[qjs(constructor)]
247+
pub fn new() -> Self { TextEncoder {} }
248+
249+
pub fn encode<'js>(&self, ctx: Ctx<'js>, input: String)
250+
-> rquickjs::Result<TypedArray<'js, u8>> {
251+
TypedArray::new(ctx, input.into_bytes())
252+
}
253+
}
254+
255+
fn setup_text_encoding(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
256+
TextEncoder::register(ctx)?;
257+
ctx.globals().set("TextEncoder", TextEncoder::constructor(ctx)?)?;
258+
Ok(())
259+
}
260+
261+
hyperlight_js_runtime::custom_globals! {
262+
setup_text_encoding,
263+
}
264+
```
265+
266+
### Combined with native modules
267+
268+
Both macros can be used together — the binary just needs to invoke both:
269+
270+
```rust
271+
hyperlight_js_runtime::native_modules! {
272+
"math" => js_math,
273+
}
274+
275+
hyperlight_js_runtime::custom_globals! {
276+
setup_text_encoding,
277+
}
278+
```
279+
280+
### `custom_globals!`
281+
282+
```rust
283+
hyperlight_js_runtime::custom_globals! {
284+
setup_fn_a,
285+
setup_fn_b,
286+
}
287+
```
288+
289+
Generates an `init_custom_globals(ctx)` function that calls each setup
290+
function in order. Called automatically by `JsRuntime::new()` after
291+
built-in globals are installed. Each setup function receives `&Ctx` and
292+
can register constructors, objects, or values on `ctx.globals()`.
293+
294+
**Important:** Every binary that links `hyperlight-js-runtime` must invoke
295+
this macro (even if empty). The base runtime's `main.rs` already does this
296+
with `custom_globals! {}` — same pattern as `native_modules!`.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ impl JsRuntime {
103103
host_loader.install(&ctx)?;
104104

105105
// Setup the global objects in the context, so they are available to the handler scripts.
106-
globals::setup(&ctx).catch(&ctx)
106+
globals::setup(&ctx).catch(&ctx)?;
107+
108+
// Setup custom globals registered by extender crates via custom_globals! macro.
109+
// Runs after built-in globals so custom setup can reference console, require, etc.
110+
modules::setup_custom_globals(&ctx).catch(&ctx)
107111
})?;
108112

109113
Ok(Self {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ limitations under the License.
2222
// See: docs/extending-runtime.md
2323
hyperlight_js_runtime::native_modules! {}
2424

25+
// Provide the `init_custom_globals` symbol required by JsRuntime::new().
26+
// The upstream binary has no custom globals so this is empty.
27+
// Extender binaries list their custom globals setup functions here instead.
28+
hyperlight_js_runtime::custom_globals! {}
29+
2530
// The hyperlight guest entry point (hyperlight_main, guest_dispatch_function,
2631
// etc.) is provided by the lib's `guest` module.
2732
// The binary only needs to provide the native CLI entry point.

src/hyperlight-js-runtime/src/modules/mod.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,84 @@ macro_rules! native_modules {
170170
}
171171
};
172172
}
173+
174+
// ── Custom globals ─────────────────────────────────────────────────────────
175+
176+
/// Call the extender crate's custom globals setup function.
177+
///
178+
/// The `init_custom_globals` symbol is generated by the [`custom_globals!`]
179+
/// macro. We call it once during `JsRuntime::new()`, after the built-in
180+
/// globals (console, require, print, etc.) have been set up.
181+
pub fn setup_custom_globals(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
182+
unsafe extern "Rust" {
183+
fn init_custom_globals(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()>;
184+
}
185+
// SAFETY: init_custom_globals is generated by the custom_globals! macro.
186+
// Every binary that links hyperlight-js-runtime must invoke the macro
187+
// (even if empty) to provide this symbol — same pattern as native_modules!.
188+
unsafe { init_custom_globals(ctx) }
189+
}
190+
191+
/// Register custom global setup functions for the JS runtime.
192+
///
193+
/// Custom globals are installed after built-in globals (console, require, etc.)
194+
/// during `JsRuntime::new()`. Each setup function receives the QuickJS `Ctx`
195+
/// and can register constructors, objects, or values on `ctx.globals()`.
196+
///
197+
/// Supports both Rust-implemented globals (via rquickjs class/function
198+
/// attributes) and JavaScript polyfills (via `ctx.eval()`).
199+
///
200+
/// # Example — Rust class
201+
///
202+
/// ```rust,ignore
203+
/// #[rquickjs::class]
204+
/// #[derive(Trace, JsLifetime)]
205+
/// pub struct TextEncoder {}
206+
///
207+
/// #[rquickjs::methods]
208+
/// impl TextEncoder {
209+
/// #[qjs(constructor)]
210+
/// pub fn new() -> Self { TextEncoder {} }
211+
/// pub fn encode<'js>(&self, ctx: Ctx<'js>, input: String)
212+
/// -> rquickjs::Result<TypedArray<'js, u8>> {
213+
/// TypedArray::new(ctx, input.into_bytes())
214+
/// }
215+
/// }
216+
///
217+
/// fn setup_text_encoding(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
218+
/// TextEncoder::register(ctx)?;
219+
/// ctx.globals().set("TextEncoder", TextEncoder::constructor(ctx)?)?;
220+
/// Ok(())
221+
/// }
222+
///
223+
/// hyperlight_js_runtime::custom_globals! {
224+
/// setup_text_encoding,
225+
/// }
226+
/// ```
227+
///
228+
/// # Example — JavaScript polyfill
229+
///
230+
/// ```rust,ignore
231+
/// fn setup_polyfills(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
232+
/// ctx.eval::<(), _>(r#"
233+
/// globalThis.MY_CONSTANT = 42;
234+
/// "#)?;
235+
/// Ok(())
236+
/// }
237+
///
238+
/// hyperlight_js_runtime::custom_globals! {
239+
/// setup_polyfills,
240+
/// }
241+
/// ```
242+
#[macro_export]
243+
macro_rules! custom_globals {
244+
($($setup_fn:expr),* $(,)?) => {
245+
/// Called by the hyperlight runtime to register custom globals
246+
/// after built-in globals are set up.
247+
#[unsafe(no_mangle)]
248+
pub fn init_custom_globals(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
249+
$( ($setup_fn)(ctx)?; )*
250+
Ok(())
251+
}
252+
};
253+
}

src/hyperlight-js-runtime/tests/fixtures/extended_runtime/src/main.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ hyperlight_js_runtime::native_modules! {
3232
"math" => js_math,
3333
}
3434

35+
// Register custom globals for the extended runtime.
36+
fn setup_test_global(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
37+
ctx.eval::<(), _>("globalThis.CUSTOM_GLOBAL_TEST = 42;")?;
38+
Ok(())
39+
}
40+
41+
hyperlight_js_runtime::custom_globals! {
42+
setup_test_global,
43+
}
44+
3545
// ── Native CLI entry point (for dev/testing) ───────────────────────────────
3646

3747
#[cfg(not(hyperlight))]

src/hyperlight-js-runtime/tests/native_modules.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,18 @@ hyperlight_js_runtime::native_modules! {
185185
"test_math_macro" => js_test_math,
186186
}
187187

188+
// ── custom_globals! macro ──────────────────────────────────────────────────
189+
190+
fn setup_test_constant(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> {
191+
ctx.eval::<(), _>("globalThis.TEST_CUSTOM_GLOBAL = 99;")?;
192+
Ok(())
193+
}
194+
195+
// The macro generates init_custom_globals() which calls each setup function
196+
hyperlight_js_runtime::custom_globals! {
197+
setup_test_constant,
198+
}
199+
188200
#[test]
189201
fn macro_generated_init_registers_modules() {
190202
init_native_modules();
@@ -445,3 +457,120 @@ fn full_pipeline_console_log_with_custom_modules() {
445457
let lines: Vec<&str> = stdout.trim().lines().collect();
446458
assert_eq!(lines, ["computed: 54", "Handler result: 54"]);
447459
}
460+
461+
// ── custom_globals! tests ──────────────────────────────────────────────────
462+
463+
#[test]
464+
fn e2e_custom_globals_are_available_in_handlers() {
465+
let mut runtime =
466+
hyperlight_js_runtime::JsRuntime::new(NoOpHost).expect("Failed to create JsRuntime");
467+
468+
let handler_script = r#"
469+
export function handler(event) {
470+
return TEST_CUSTOM_GLOBAL;
471+
}
472+
"#;
473+
474+
runtime
475+
.register_handler("globals_handler", handler_script, ".")
476+
.expect("Failed to register handler");
477+
478+
let result = runtime
479+
.run_handler("globals_handler".to_string(), "{}".to_string(), false)
480+
.expect("Failed to run handler");
481+
482+
assert_eq!(result, "99");
483+
}
484+
485+
#[test]
486+
fn e2e_custom_globals_coexist_with_builtins() {
487+
let mut runtime =
488+
hyperlight_js_runtime::JsRuntime::new(NoOpHost).expect("Failed to create JsRuntime");
489+
490+
let handler_script = r#"
491+
export function handler(event) {
492+
// Built-in console should still work
493+
console.log("custom global value: " + TEST_CUSTOM_GLOBAL);
494+
return TEST_CUSTOM_GLOBAL;
495+
}
496+
"#;
497+
498+
runtime
499+
.register_handler("globals_coexist", handler_script, ".")
500+
.expect("Failed to register handler");
501+
502+
let result = runtime
503+
.run_handler("globals_coexist".to_string(), "{}".to_string(), false)
504+
.expect("Failed to run handler");
505+
506+
assert_eq!(result, "99");
507+
}
508+
509+
// ── Full pipeline custom globals tests ─────────────────────────────────────
510+
511+
#[test]
512+
fn full_pipeline_custom_globals_available() {
513+
let binary = &*EXTENDED_RUNTIME_BINARY;
514+
let dir = tempfile::tempdir().unwrap();
515+
516+
std::fs::write(
517+
dir.path().join("handler.js"),
518+
r#"
519+
function handler(event) {
520+
return CUSTOM_GLOBAL_TEST;
521+
}
522+
"#,
523+
)
524+
.unwrap();
525+
526+
let output = Command::new(binary)
527+
.arg(dir.path().join("handler.js"))
528+
.arg("{}")
529+
.output()
530+
.unwrap();
531+
532+
assert!(
533+
output.status.success(),
534+
"Failed:\n{}",
535+
String::from_utf8_lossy(&output.stderr)
536+
);
537+
let stdout = String::from_utf8_lossy(&output.stdout);
538+
assert!(
539+
stdout.contains("Handler result: 42"),
540+
"Expected custom global to be 42, got: {stdout}"
541+
);
542+
}
543+
544+
#[test]
545+
fn full_pipeline_custom_globals_with_modules() {
546+
let binary = &*EXTENDED_RUNTIME_BINARY;
547+
let dir = tempfile::tempdir().unwrap();
548+
549+
std::fs::write(
550+
dir.path().join("handler.js"),
551+
r#"
552+
import { add } from "math";
553+
function handler(event) {
554+
return add(CUSTOM_GLOBAL_TEST, event.x);
555+
}
556+
"#,
557+
)
558+
.unwrap();
559+
560+
let output = Command::new(binary)
561+
.arg(dir.path().join("handler.js"))
562+
.arg(r#"{"x":8}"#)
563+
.output()
564+
.unwrap();
565+
566+
assert!(
567+
output.status.success(),
568+
"Failed:\n{}",
569+
String::from_utf8_lossy(&output.stderr)
570+
);
571+
let stdout = String::from_utf8_lossy(&output.stdout);
572+
assert!(
573+
stdout.contains("Handler result: 50"),
574+
"Expected 42 + 8 = 50, got: {stdout}"
575+
);
576+
}

0 commit comments

Comments
 (0)