hyperlight-js sandboxes can be captured to hyperlight's OCI image-layout
snapshot format and restored after the original sandbox or process has exited.
Snapshots are immutable and may be addressed by a human-readable tag or their
sha256: manifest digest.
Two lifecycle states are supported:
JSSandboxsnapshots contain the initialized JavaScript runtime together with handler and module definitions added toJSSandboxbut not yet registered in the guest.LoadedJSSandboxsnapshots additionally contain evaluated guest handlers, closures, and current mutable guest state, including module-scoped state.
An OCI image layout is one directory on disk. The same layout can contain any
number of independently tagged JSSandbox and LoadedJSSandbox snapshots;
each tag or manifest digest identifies one snapshot.
In-memory restore() remains the fastest way to roll back an existing sandbox.
Snapshot::save() and Snapshot::load() provide a durable, cross-process
workflow.
use hyperlight_js::{LoadedJSSandbox, OciTag, SandboxBuilder, Script, Snapshot};
fn main() -> hyperlight_js::Result<()> {
let mut sandbox = SandboxBuilder::new().build()?.load_runtime()?;
sandbox.add_handler(
"handler",
Script::from_content("function handler(event) { return event; }"),
)?;
let mut loaded = sandbox.get_loaded_sandbox()?;
let snapshot = loaded.snapshot()?;
let digest = snapshot.save("./snapshots", &OciTag::new("application-ready")?)?;
// Later, potentially in another process.
// The tag can be used instead of the digest.
let snapshot = Snapshot::<LoadedJSSandbox>::checked_load("./snapshots", digest)?;
let _loaded = SandboxBuilder::from_snapshot(snapshot).build()?;
Ok(())
}Snapshot<T> names the sandbox type the snapshot captured. Loading fails if the
saved snapshot holds the other type. build() returns T.
const { SandboxBuilder, Snapshot } = require('@hyperlight-dev/js-host-api');
const snapshot = await loaded.snapshot();
const digest = await snapshot.save('./snapshots', 'application-ready');
// Later, potentially in another process.
// 'application-ready' can be used instead of digest to load by tag.
const persisted = await Snapshot.checkedLoad('./snapshots', digest);
const restored = await SandboxBuilder.fromSnapshot(persisted).build();build() returns a JSSandbox or LoadedJSSandbox, matching the saved
snapshot. TypeScript types the result as JSSandbox | LoadedJSSandbox. Narrow it
with instanceof.
Configure process-local host resources on the builder before calling build().
A snapshot's memory layout overrides scratch and transport settings.
Snapshot::checked_load() in Rust and Snapshot.checkedLoad() in Node check
every OCI blob against its recorded digest. Snapshot::load() and
Snapshot.load() skip the check and reduce I/O. Use them only for trusted local
layouts. All four accept a tag or a manifest digest.
The loaded blobs stay memory-mapped while the snapshot or any sandbox built from it is alive. Overwriting, truncating, or deleting them during that time can corrupt guest memory.
hyperlight-js has four mechanisms that are all visible to JavaScript as modules but have different ownership and restore behavior.
| Category | How it is provided | Where code executes | Snapshot behavior | Restore action |
|---|---|---|---|---|
| Host-function module | HostFunctionModules containing Rust closures or Node callbacks |
Host process through CallHostJsFunction |
Records the supplied module and function names, not implementations | Supply fresh modules to SandboxBuilder |
| Embedded JavaScript module | JSSandbox::add_module() / addModule() |
Guest QuickJS runtime | A JSSandbox snapshot stores staged source in hyperlight-js metadata. A LoadedJSSandbox snapshot captures the guest module-loader map and any evaluated module state. |
None; hyperlight-js restores it automatically |
| External JavaScript module | Rust SandboxBuilder::with_module_loader() backed by ResolveModule and LoadModule |
Guest QuickJS runtime after source is returned by the host | Loaded/evaluated state is captured, but source returned by the loader is not currently archived | Supply a compatible loader to the restoring builder when the underlying snapshot requires it |
| Native guest module | Rust compiled into hyperlight-js-runtime with rquickjs |
Guest runtime | Native module code and state are already resident in the captured guest VM | None |
Write one function that builds the modules, and call it for each fresh or
restored builder. A builder consumes its modules, so each call must create fresh
module objects, callbacks, and state. Create mutable state inside the function
to isolate it per sandbox. Capturing an external Arc, static, or JavaScript
variable shares that state between sandboxes, which breaks the Sandbox isolation
model.
Rust:
use hyperlight_js::{HostFunctionModule, JSSandbox, OciTag, SandboxBuilder, Snapshot};
fn host_function_modules() -> Vec<HostFunctionModule> {
let mut database = HostFunctionModule::new("host:database");
database.register("query", |id: u64| format!("record-{id}"));
vec![database]
}
let fresh = SandboxBuilder::new()
.with_host_function_modules(host_function_modules())
.build()?
.load_runtime()?;
let snapshot =
Snapshot::<JSSandbox>::checked_load("./snapshots", OciTag::new("with-host-functions")?)?;
let restored = SandboxBuilder::from_snapshot(snapshot)
.with_host_function_modules(host_function_modules())
.build()?;Node.js:
const {
HostFunctionModule,
SandboxBuilder,
Snapshot,
} = require('@hyperlight-dev/js-host-api');
function hostFunctionModules() {
const database = new HostFunctionModule('database');
database.register('query', query);
return [database];
}
const freshProto = await new SandboxBuilder()
.setHostFunctionModules(hostFunctionModules())
.build();
const fresh = await freshProto.loadRuntime();
const snapshot = await Snapshot.checkedLoad('./snapshots', 'with-host-functions');
const restored = await SandboxBuilder.fromSnapshot(snapshot)
.setHostFunctionModules(hostFunctionModules())
.build();A snapshot records the host functions its guest state needs. build() fails
before running the guest if a recorded module or function is not supplied.
The guest can call every supplied host function, including ones the snapshot
does not record. Handlers loaded after unload() can import them too. Supply
only the modules the guest should reach.
If the builder supplies host functions the snapshot does not record,
hyperlight-js registers them in the guest and copies the guest state. A
JSSandbox does this in build() and in each restore() of such a snapshot.
A LoadedJSSandbox does it in unload() after such a restore. Supply exactly
the recorded functions to avoid this cost.
A loaded snapshot records the host functions its evaluated handlers need. Saving
it again after a restore keeps that record, even when the restoring builder
supplied more functions. A JSSandbox snapshot records every function supplied
to its builder, because its staged handlers are not compiled yet.
hyperlight validates only the typed CallHostJsFunction bridge. hyperlight-js
therefore validates individual logical module and function names itself. It
cannot verify argument types, return types, or behavior.
hyperlight-js#339 tracks stable
semantic signatures.
Modules added with add_module() / addModule() are guest application state.
For a JSSandbox snapshot, handlers and modules have not yet been transferred
to the guest. hyperlight-js therefore stores their source in snapshot metadata
and reconstructs the staged definitions automatically during restoration.
hyperlight limits that metadata to 1 MiB, so save() fails when the staged
sources exceed it. Load large sources into a LoadedJSSandbox before saving.
Creating a LoadedJSSandbox transfers every embedded module source into the
guest's module-loader map before handlers are registered. A loaded snapshot
therefore captures every registered source in guest memory, including modules
that have not yet been imported or evaluated. It also captures evaluated module
instances and mutable module-scoped state. Callers do not add embedded modules
again after restoring either lifecycle state.
The external loader is lazy. ResolveModule and LoadModule run only when
QuickJS traverses an import that earlier loaders did not satisfy. Modules never
imported before the snapshot are not present in guest memory.
let snapshot =
Snapshot::<JSSandbox>::checked_load("./snapshots", OciTag::new("with-module-loader")?)?;
let sandbox = SandboxBuilder::from_snapshot(snapshot)
.with_module_loader(file_system)
.build()?;hyperlight records ResolveModule and LoadModule, including their exact type
signatures, in its snapshot. Missing or mismatched replacements produce
SnapshotHostFunctionMismatch before guest execution. hyperlight-js translates
that mismatch into a module-loader-specific error. Matching signatures do not
prove that a replacement loader resolves the same paths or returns the same
source.
hyperlight-js does not currently archive source returned by an arbitrary module
loader. A future capture API must distinguish sources observed through
LoadModule from a complete, explicitly supplied archive: observed sources do
not cover dynamic imports that have not executed yet.
Native modules execute inside the guest and are already resident in guest VM
state when a snapshot is taken. They are restored with that VM state and do not
require host-side registration. The JSRUNTIME embedded in the current host
binary is used only when constructing a fresh sandbox, not when restoring one.
CPU architecture and hyperlight snapshot-format compatibility still apply.
hyperlight checks ResolveModule and LoadModule during build(). It does not
yet expose a snapshot's host-function requirements for inspection.
hyperlight#1870 tracks that. Until then,
hyperlight-js cannot tell before building whether a snapshot needs a module
loader. Supply one when the application's contract requires it, or handle the
build error.
HostPrint is handled separately. hyperlight provides a default stdout printer
when no custom handler is supplied. Reinstall a custom print handler only when
the same output routing is required.
- Snapshots are tied to their CPU architecture, CPU vendor, hypervisor, and hyperlight snapshot format. For example, a KVM snapshot taken on an Intel host does not load on AMD or under MSHV.
- hyperlight-js metadata is schema-versioned and records the producing package version for diagnostics. It also records the guest runtime ABI version. Unsupported metadata schema or guest ABI versions are rejected before starting guest execution.
- Snapshots saved by hyperlight-js 0.5.0 use metadata schema 1 and are rejected. Recreate them.
- Digest verification detects accidental or malicious mutation, but does not authenticate who produced a snapshot. Authenticate snapshot artifacts through the distribution mechanism used by your application.
A persisted LoadedJSSandbox consists of two hyperlight snapshots in the same
OCI layout:
- The loaded application state, including registered handlers, modules, and mutable guest state.
- A raw runtime-ready rollback state captured before user handlers and modules were registered.
The rollback state is not an independently restorable hyperlight-js
JSSandbox snapshot: it has no hyperlight-js lifecycle metadata or
staged handler and module definitions. The loaded snapshot's hyperlight-js
metadata stores its digest solely so unload() can discard evaluated handlers,
modules, and mutable guest state and return a clean JSSandbox after restore.
The digest is stored only in hyperlight-js metadata, not as an OCI descriptor. Standard OCI tools therefore do not discover or copy the baseline automatically. hyperlight#1865 tracks native OCI bundle support for this relationship.
Until hyperlight supports OCI snapshot bundles, a
persisted LoadedJSSandbox uses two OCI tags:
application-ready.hljs-base: the runtime-ready baselineapplication-ready: the loaded guest state
save() rejects user tags ending in .hljs-base, so a user tag cannot replace a
baseline.
Copy both tags into the same repository or OCI layout. Push the baseline first so the public application tag is never published without its dependency:
oras cp --from-oci-layout \
./snapshots:application-ready.hljs-base \
registry.example.com/snapshots:application-ready.hljs-base
oras cp --from-oci-layout \
./snapshots:application-ready \
registry.example.com/snapshots:application-readyRestore both tags into the same local OCI layout before loading the snapshot:
oras cp --to-oci-layout \
registry.example.com/snapshots:application-ready.hljs-base \
./snapshots:application-ready.hljs-base
oras cp --to-oci-layout \
registry.example.com/snapshots:application-ready \
./snapshots:application-readyThe oras cp --recursive option copies OCI referrers. It does not discover the
hyperlight-js baseline because the relationship is currently stored in
application metadata rather than an OCI descriptor.
The Rust persistent_snapshot example and Node
examples/persistent-snapshot.js both have separate write and read
commands, demonstrating restoration in a new process.
Criterion benchmarks under persistent_snapshots measure save, verified load,
and construction of a new LoadedJSSandbox from disk.