Skip to content

feat: add user module registration for reusable ES modules#45

Open
simongdavies wants to merge 1 commit intohyperlight-dev:mainfrom
simongdavies:add-modules
Open

feat: add user module registration for reusable ES modules#45
simongdavies wants to merge 1 commit intohyperlight-dev:mainfrom
simongdavies:add-modules

Conversation

@simongdavies
Copy link
Contributor

Add add_module/remove_module/clear_modules API to JSSandbox, allowing handlers (and other modules) to import reusable ES modules using the 'namespace:name' convention (default namespace: 'user').

Guest runtime:

  • Add UserModuleLoader (Resolver + Loader) for lazy compilation
  • Add register_module guest function
  • Modules compiled on first import, avoiding ordering issues

Host library (hyperlight-js):

  • Add modules HashMap to JSSandbox with full CRUD API
  • add_module / add_module_ns / remove_module / remove_module_ns / clear_modules
  • Modules registered before handlers in get_loaded_sandbox()
  • Export DEFAULT_MODULE_NAMESPACE constant
  • Namespace validation: 'host' reserved, no colons allowed

NAPI wrapper (js-host-api):

  • Add addModule / removeModule / clearModules on JSSandboxWrapper
  • Error enrichment wrapping for new sync methods

Import capabilities:

  • User modules can import other user modules
  • User modules can import built-in modules (crypto, console, etc.)
  • User modules can import host function modules (host:X)
  • Missing modules fail at getLoadedSandbox() with clear errors

@simongdavies simongdavies added the kind/enhancement New feature or improvement label Mar 9, 2026
Add add_module/remove_module/clear_modules API to JSSandbox, allowing
handlers (and other modules) to import reusable ES modules using the
'namespace:name' convention (default namespace: 'user').

Guest runtime:
- Add UserModuleLoader (Resolver + Loader) for lazy compilation
- Add register_module guest function
- Modules compiled on first import, avoiding ordering issues

Host library (hyperlight-js):
- Add modules HashMap to JSSandbox with full CRUD API
- add_module / add_module_ns / remove_module / remove_module_ns / clear_modules
- Modules registered before handlers in get_loaded_sandbox()
- Export DEFAULT_MODULE_NAMESPACE constant
- Namespace validation: 'host' reserved, no colons allowed

NAPI wrapper (js-host-api):
- Add addModule / removeModule / clearModules on JSSandboxWrapper
- Error enrichment wrapping for new sync methods

Import capabilities:
- User modules can import other user modules
- User modules can import built-in modules (crypto, console, etc.)
- User modules can import host function modules (host:X)
- Missing modules fail at getLoadedSandbox() with clear errors

Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
Copy link
Contributor

@ludfjig ludfjig left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the rust code, looks good!

Comment on lines +220 to +238
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(())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar logic is also used by add_module_ns in js_sandbox.rs (but not exactly). Could we factor it out maybe to have consistent implementations? I'm not sure I agree with the comment

/// 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!()`.

self.user_modules
.modules
.borrow_mut()
.insert(module_name, module_source.into());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if this is already registered? Can we document this?

Comment on lines +266 to +270
/// # 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's unclear what happens if it's empty, maybe we could mention that it just returns an error in this case

}
if value.len() > MAX_MODULE_IDENTIFIER_LEN {
return Err(invalid_arg_error(&format!(
"{label} must not exceed {MAX_MODULE_IDENTIFIER_LEN} characters"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: error message should state bytes instead of characters

Comment on lines +246 to +257
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 ':'"));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is duplicated with add_module_rs, but why do we need to validate this regardless? Wouldn't it be caught by below self.modules.remove()?


/// 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
Copy link
Contributor

@ludfjig ludfjig Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we share a variable between crates instead?

}

#[test]
fn test_remove_module() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we add a test that also runs some script after removing a module, to make sure things are removed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/enhancement New feature or improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants