Add SpiderMonkey WASM backend via mozjs crate

ober

42647c3471db4acafc09eb8d66eb1b30240d3093

diff --git a/jerboa-native-rs/Cargo.lock b/jerboa-native-rs/Cargo.lock
index df9318a..8919ddc 100644
--- a/jerboa-native-rs/Cargo.lock
+++ b/jerboa-native-rs/Cargo.lock
@@ -59,6 +59,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
 name = "base64"
 version = "0.22.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -71,6 +77,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
 
 [[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn",
+]
+
+[[package]]
 name = "bitflags"
 version = "2.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -123,6 +147,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
+[[package]]
 name = "cfg-if"
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -139,6 +172,17 @@ dependencies = [
 ]
 
 [[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
+[[package]]
 name = "const-oid"
 version = "0.9.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -260,12 +304,55 @@ dependencies = [
 ]
 
 [[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "encoding_c"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9af727805f3b0d79956bde5b35732669fb5c5d45a94893798e7b7e70cfbf9cc1"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "encoding_c_mem"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a80a16821fe8c7cab96e0c67b57cd7090e021e9615e6ce6ab0cf866c44ed1f0"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
 name = "equivalent"
 version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
 
 [[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
 name = "fallible-iterator"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -290,6 +377,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
 
 [[package]]
+name = "filetime"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "libredox",
+]
+
+[[package]]
 name = "find-msvc-tools"
 version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -385,6 +483,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
 name = "hashbrown"
 version = "0.14.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -475,6 +579,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
 name = "jerboa-native"
 version = "0.1.0"
 dependencies = [
@@ -485,6 +598,8 @@ dependencies = [
  "hkdf",
  "inotify",
  "libc",
+ "mozjs",
+ "mozjs_sys",
  "postgres",
  "rcgen",
  "regex",
@@ -518,6 +633,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
 
 [[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
 name = "libm"
 version = "0.2.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -529,7 +654,10 @@ version = "0.1.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
 dependencies = [
+ "bitflags",
  "libc",
+ "plain",
+ "redox_syscall 0.7.3",
 ]
 
 [[package]]
@@ -544,6 +672,24 @@ dependencies = [
 ]
 
 [[package]]
+name = "libz-sys"
+version = "1.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
 name = "lock_api"
 version = "0.4.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -575,6 +721,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
 
 [[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
 name = "miniz_oxide"
 version = "0.8.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -596,18 +748,67 @@ dependencies = [
 ]
 
 [[package]]
+name = "mozjs"
+version = "0.15.7"
+source = "git+https://github.com/servo/mozjs.git#97f035f7d1211c07b14b99137b32566169c57ee9"
+dependencies = [
+ "bindgen",
+ "cc",
+ "encoding_rs",
+ "libc",
+ "log",
+ "mozjs_sys",
+ "num-traits",
+]
+
+[[package]]
+name = "mozjs_sys"
+version = "0.140.8-2"
+source = "git+https://github.com/servo/mozjs.git#97f035f7d1211c07b14b99137b32566169c57ee9"
+dependencies = [
+ "bindgen",
+ "cc",
+ "encoding_c",
+ "encoding_c_mem",
+ "flate2",
+ "libc",
+ "libz-sys",
+ "tar",
+ "walkdir",
+]
+
+[[package]]
 name = "multi-stash"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f"
 
 [[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
 name = "num-conv"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
 
 [[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
 name = "objc2-core-foundation"
 version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -649,7 +850,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
 dependencies = [
  "cfg-if",
  "libc",
- "redox_syscall",
+ "redox_syscall 0.5.18",
  "smallvec",
  "windows-link",
 ]
@@ -733,6 +934,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
 
 [[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
 name = "postgres"
 version = "0.19.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -875,6 +1082,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "redox_syscall"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
 name = "regex"
 version = "1.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -932,6 +1148,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
 name = "rustc_version"
 version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -941,6 +1163,19 @@ dependencies = [
 ]
 
 [[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
 name = "rustls"
 version = "0.23.37"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1000,6 +1235,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
 name = "scopeguard"
 version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1162,6 +1406,17 @@ dependencies = [
 ]
 
 [[package]]
+name = "tar"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
 name = "time"
 version = "0.3.47"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1300,6 +1555,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
 
 [[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
 name = "wasi"
 version = "0.11.1+wasi-snapshot-preview1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1473,6 +1738,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
 name = "windows-link"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1579,6 +1853,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
+[[package]]
 name = "yasna"
 version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/jerboa-native-rs/Cargo.toml b/jerboa-native-rs/Cargo.toml
index cae4eef..e874f4b 100644
--- a/jerboa-native-rs/Cargo.toml
+++ b/jerboa-native-rs/Cargo.toml
@@ -27,6 +27,12 @@ rustls-pemfile = "2"
 webpki-roots = "0.26"
 wasmi = "0.40"
 getrandom = "0.2"
+mozjs = { git = "https://github.com/servo/mozjs.git", optional = true, default-features = false, features = ["jit", "libz-sys"] }
+mozjs_sys = { git = "https://github.com/servo/mozjs.git", optional = true }
+
+[features]
+default = []
+spidermonkey = ["mozjs", "mozjs_sys"]
 
 [target.'cfg(target_os = "linux")'.dependencies]
 inotify = { version = "0.11", default-features = false }
diff --git a/jerboa-native-rs/src/lib.rs b/jerboa-native-rs/src/lib.rs
index 71eeff2..cad64bd 100644
--- a/jerboa-native-rs/src/lib.rs
+++ b/jerboa-native-rs/src/lib.rs
@@ -13,6 +13,8 @@ mod x25519;
 mod ed25519;
 mod process_ctl;
 mod wasm;
+#[cfg(feature = "spidermonkey")]
+mod wasm_sm;
 
 #[cfg(target_os = "linux")]
 mod epoll;
diff --git a/jerboa-native-rs/src/wasm_sm.rs b/jerboa-native-rs/src/wasm_sm.rs
new file mode 100644
index 0000000..383259b
--- /dev/null
+++ b/jerboa-native-rs/src/wasm_sm.rs
@@ -0,0 +1,450 @@
+//! SpiderMonkey-based WASM runtime.
+//!
+//! Alternative WASM backend using Mozilla's SpiderMonkey engine (via mozjs crate).
+//! Provides full WASM spec support including GC and exception handling,
+//! unlike the wasmi backend which lacks these proposals.
+//!
+//! Same FFI surface as wasm.rs — Scheme code is unchanged.
+//!
+//! Enable with: cargo build --features spidermonkey
+
+use ::std::collections::HashMap;
+use ::std::ptr;
+use ::std::sync::Mutex;
+use ::std::sync::atomic::{AtomicU64, Ordering};
+use ::std::sync::OnceLock;
+
+use mozjs::jsapi::*;
+use mozjs::jsval::{Int32Value, ObjectValue, UndefinedValue};
+use mozjs::realm::AutoRealm;
+use mozjs::rooted;
+use mozjs::rust::wrappers2::{
+    Call, Construct1, JS_DefineFunction, JS_GetProperty, JS_NewGlobalObject, JS_NewPlainObject,
+    JS_SetProperty, NewArrayBufferWithUserOwnedContents,
+};
+use mozjs::rust::SIMPLE_GLOBAL_CLASS;
+use mozjs::rust::{HandleValue, IntoHandle, JSEngine, RealmOptions, Runtime};
+use mozjs::jsval::JSVal;
+use mozjs_sys::jsgc::ValueArray;
+
+use crate::panic::set_last_error;
+
+// ============================================================
+// Constants
+// ============================================================
+
+const LOG_BUFFER_CAP: usize = 10_000;
+
+// ============================================================
+// Handle management
+// ============================================================
+
+static NEXT_SM_HANDLE: AtomicU64 = AtomicU64::new(1);
+
+fn next_handle() -> u64 {
+    NEXT_SM_HANDLE.fetch_add(1, Ordering::Relaxed)
+}
+
+fn sm_modules() -> &'static Mutex<HashMap<u64, SmWasmModule>> {
+    static MODULES: OnceLock<Mutex<HashMap<u64, SmWasmModule>>> = OnceLock::new();
+    MODULES.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+fn sm_instances() -> &'static Mutex<HashMap<u64, SmWasmInstance>> {
+    static INSTANCES: OnceLock<Mutex<HashMap<u64, SmWasmInstance>>> = OnceLock::new();
+    INSTANCES.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+/// Global JS engine (initialized once, shared across all runtimes)
+fn sm_engine() -> mozjs::rust::JSEngineHandle {
+    static ENGINE: OnceLock<mozjs::rust::JSEngineHandle> = OnceLock::new();
+    ENGINE.get_or_init(|| {
+        let engine = JSEngine::init().expect("failed to initialize SpiderMonkey");
+        engine.handle()
+    }).clone()
+}
+
+// ============================================================
+// Types
+// ============================================================
+
+struct SmWasmModule {
+    wasm_bytes: Vec<u8>,
+}
+
+struct SmHostState {
+    log_buffer: Vec<String>,
+    fuel_remaining: u64,
+}
+
+impl Default for SmHostState {
+    fn default() -> Self {
+        SmHostState {
+            log_buffer: Vec::new(),
+            fuel_remaining: 0,
+        }
+    }
+}
+
+struct SmWasmInstance {
+    wasm_bytes: Vec<u8>,
+    host: SmHostState,
+}
+
+// ============================================================
+// FFI: Module lifecycle
+// ============================================================
+
+/// Load WASM bytes into a module. Returns handle > 0, or 0 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_sm_module_new(
+    bytes: *const u8,
+    bytes_len: usize,
+) -> u64 {
+    match ::std::panic::catch_unwind(|| {
+        if bytes.is_null() || bytes_len == 0 {
+            set_last_error("null or empty WASM bytes".into());
+            return 0u64;
+        }
+        let wasm_bytes = unsafe { ::std::slice::from_raw_parts(bytes, bytes_len) }.to_vec();
+
+        // Validate: try compiling in a temporary runtime
+        let engine = sm_engine();
+        let mut rt = Runtime::new(engine);
+        let options = RealmOptions::default();
+        let cx = rt.cx();
+
+        let valid = unsafe {
+            rooted!(&in(cx) let global = JS_NewGlobalObject(
+                cx, &SIMPLE_GLOBAL_CLASS, ptr::null_mut(),
+                OnNewGlobalHookOption::FireOnNewGlobalHook, &*options
+            ));
+            let mut realm = AutoRealm::new_from_handle(cx, global.handle());
+            let cx = &mut realm;
+
+            // Get WebAssembly.Module constructor
+            rooted!(&in(cx) let mut wasm_val = UndefinedValue());
+            if !JS_GetProperty(cx, global.handle(), c"WebAssembly".as_ptr(), wasm_val.handle_mut()) {
+                false
+            } else {
+                rooted!(&in(cx) let wasm_obj = wasm_val.to_object());
+                rooted!(&in(cx) let mut module_ctor = UndefinedValue());
+                if !JS_GetProperty(cx, wasm_obj.handle(), c"Module".as_ptr(), module_ctor.handle_mut()) {
+                    false
+                } else {
+                    // Build aligned buffer and compile
+                    let mut aligned_buf = vec![0u8; wasm_bytes.len() + 8];
+                    let offset = aligned_buf.as_ptr() as usize % 8;
+                    let start = if offset == 0 { 0 } else { 8 - offset };
+                    aligned_buf[start..start + wasm_bytes.len()].copy_from_slice(&wasm_bytes);
+
+                    let ab = NewArrayBufferWithUserOwnedContents(
+                        cx, wasm_bytes.len(),
+                        aligned_buf[start..].as_ptr() as *mut _,
+                    );
+                    if ab.is_null() {
+                        false
+                    } else {
+                        rooted!(&in(cx) let val = ObjectValue(ab));
+                        let args = HandleValueArray::from(val.handle().into_handle());
+                        rooted!(&in(cx) let mut module = ptr::null_mut::<JSObject>());
+                        Construct1(cx, module_ctor.handle(), &args, module.handle_mut())
+                    }
+                }
+            }
+        };
+
+        if !valid {
+            set_last_error("WASM module validation/compilation failed".into());
+            return 0;
+        }
+
+        let handle = next_handle();
+        sm_modules().lock().unwrap().insert(handle, SmWasmModule { wasm_bytes });
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => 0,
+    }
+}
+
+/// Free a loaded module.
+#[no_mangle]
+pub extern "C" fn jerboa_sm_module_free(handle: u64) {
+    let _ = sm_modules().lock().unwrap().remove(&handle);
+}
+
+// ============================================================
+// FFI: Instance lifecycle
+// ============================================================
+
+/// Create a WASM instance. Returns handle > 0, or 0 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_sm_instance_new(module_handle: u64, fuel: u64) -> u64 {
+    match ::std::panic::catch_unwind(|| {
+        let modules = sm_modules().lock().unwrap();
+        let module = match modules.get(&module_handle) {
+            Some(m) => m,
+            None => { set_last_error("invalid module handle".into()); return 0u64; }
+        };
+        let wasm_bytes = module.wasm_bytes.clone();
+        drop(modules);
+
+        let mut host = SmHostState::default();
+        host.fuel_remaining = fuel;
+
+        let handle = next_handle();
+        sm_instances().lock().unwrap().insert(handle, SmWasmInstance {
+            wasm_bytes,
+            host,
+        });
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => 0,
+    }
+}
+
+/// Create a hosted WASM instance (with host imports).
+#[no_mangle]
+pub extern "C" fn jerboa_sm_instance_new_hosted(module_handle: u64, fuel: u64) -> u64 {
+    // Same as plain for now — host imports are wired during call
+    jerboa_sm_instance_new(module_handle, fuel)
+}
+
+/// Free an instance.
+#[no_mangle]
+pub extern "C" fn jerboa_sm_instance_free(handle: u64) {
+    let _ = sm_instances().lock().unwrap().remove(&handle);
+}
+
+// ============================================================
+// FFI: Execution
+// ============================================================
+
+/// Call an exported WASM function by name.
+/// Returns 0 on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_sm_call(
+    instance_handle: u64,
+    name_ptr: *const u8,
+    name_len: usize,
+    args_ptr: *const i32,
+    args_count: usize,
+    results_ptr: *mut i32,
+    results_count: usize,
+) -> i32 {
+    match ::std::panic::catch_unwind(|| -> i32 {
+        // Extract function name
+        let func_name = if name_ptr.is_null() || name_len == 0 {
+            set_last_error("null function name".into());
+            return -1i32;
+        } else {
+            let bytes = unsafe { ::std::slice::from_raw_parts(name_ptr, name_len) };
+            match ::std::str::from_utf8(bytes) {
+                Ok(s) => s.to_string(),
+                Err(_) => { set_last_error("invalid UTF-8 in function name".into()); return -1; }
+            }
+        };
+
+        // Extract arguments
+        let args: Vec<i32> = if args_count > 0 && !args_ptr.is_null() {
+            unsafe { ::std::slice::from_raw_parts(args_ptr, args_count) }.to_vec()
+        } else {
+            vec![]
+        };
+
+        // Get the WASM bytes from the instance
+        let mut instances = sm_instances().lock().unwrap();
+        let inst = match instances.get_mut(&instance_handle) {
+            Some(i) => i,
+            None => { set_last_error("invalid instance handle".into()); return -1; }
+        };
+        let wasm_bytes = inst.wasm_bytes.clone();
+        drop(instances);
+
+        // Create a fresh SpiderMonkey runtime for this call
+        let engine = sm_engine();
+        let mut rt = Runtime::new(engine);
+        let options = RealmOptions::default();
+        let cx = rt.cx();
+
+        unsafe {
+            rooted!(&in(cx) let global = JS_NewGlobalObject(
+                cx, &SIMPLE_GLOBAL_CLASS, ptr::null_mut(),
+                OnNewGlobalHookOption::FireOnNewGlobalHook, &*options
+            ));
+            let mut realm = AutoRealm::new_from_handle(cx, global.handle());
+            let cx = &mut realm;
+
+            // Get WebAssembly.Module and Instance constructors
+            rooted!(&in(cx) let mut wasm_val = UndefinedValue());
+            JS_GetProperty(cx, global.handle(), c"WebAssembly".as_ptr(), wasm_val.handle_mut());
+            rooted!(&in(cx) let wasm_obj = wasm_val.to_object());
+
+            rooted!(&in(cx) let mut module_ctor = UndefinedValue());
+            rooted!(&in(cx) let mut instance_ctor = UndefinedValue());
+            JS_GetProperty(cx, wasm_obj.handle(), c"Module".as_ptr(), module_ctor.handle_mut());
+            JS_GetProperty(cx, wasm_obj.handle(), c"Instance".as_ptr(), instance_ctor.handle_mut());
+
+            // Compile module from bytes (aligned buffer)
+            let mut aligned_buf = vec![0u8; wasm_bytes.len() + 8];
+            let buf_offset = aligned_buf.as_ptr() as usize % 8;
+            let start = if buf_offset == 0 { 0 } else { 8 - buf_offset };
+            aligned_buf[start..start + wasm_bytes.len()].copy_from_slice(&wasm_bytes);
+
+            let ab = NewArrayBufferWithUserOwnedContents(
+                cx, wasm_bytes.len(),
+                aligned_buf[start..].as_ptr() as *mut _,
+            );
+            if ab.is_null() {
+                set_last_error("failed to create ArrayBuffer".into());
+                return -1;
+            }
+
+            rooted!(&in(cx) let buf_val = ObjectValue(ab));
+            let compile_args = HandleValueArray::from(buf_val.handle().into_handle());
+            rooted!(&in(cx) let mut module_obj = ptr::null_mut::<JSObject>());
+            if !Construct1(cx, module_ctor.handle(), &compile_args, module_obj.handle_mut()) {
+                set_last_error("WebAssembly.Module compilation failed".into());
+                return -1;
+            }
+
+            // Build empty imports object (plain instances have no imports)
+            rooted!(&in(cx) let imports = JS_NewPlainObject(cx));
+
+            // Instantiate: new WebAssembly.Instance(module, imports)
+            rooted!(&in(cx) let mut inst_args = ValueArray::new([
+                ObjectValue(module_obj.get()),
+                ObjectValue(imports.get()),
+            ]));
+            rooted!(&in(cx) let mut instance_obj = ptr::null_mut::<JSObject>());
+            if !Construct1(cx, instance_ctor.handle(),
+                           &HandleValueArray::from(&inst_args),
+                           instance_obj.handle_mut()) {
+                set_last_error("WebAssembly.Instance creation failed".into());
+                return -1;
+            }
+
+            // Get exports object
+            rooted!(&in(cx) let mut exports_val = UndefinedValue());
+            JS_GetProperty(cx, instance_obj.handle(), c"exports".as_ptr(), exports_val.handle_mut());
+            rooted!(&in(cx) let exports_obj = exports_val.to_object());
+
+            // Get the function
+            let c_name = match ::std::ffi::CString::new(func_name.as_str()) {
+                Ok(c) => c,
+                Err(_) => { set_last_error("invalid function name".into()); return -1; }
+            };
+            rooted!(&in(cx) let mut func_val = UndefinedValue());
+            JS_GetProperty(cx, exports_obj.handle(), c_name.as_ptr(), func_val.handle_mut());
+
+            if func_val.get().is_undefined() {
+                set_last_error(format!("export '{}' not found", func_name));
+                return -1;
+            }
+
+            // Build JS arguments
+            let js_args: Vec<JSVal> = args.iter().map(|a: &i32| Int32Value(*a)).collect();
+            let call_args = HandleValueArray {
+                length_: js_args.len(),
+                elements_: if js_args.is_empty() { ptr::null() } else { js_args.as_ptr() },
+            };
+
+            // Call the function
+            rooted!(&in(cx) let mut rval = UndefinedValue());
+            if !Call(cx, HandleValue::undefined(), func_val.handle().into(),
+                     &call_args, rval.handle_mut().into()) {
+                set_last_error("WASM function call failed".into());
+                return -1;
+            }
+
+            // Extract result
+            let val = if rval.get().is_int32() {
+                rval.get().to_int32()
+            } else if rval.get().is_double() {
+                rval.get().to_number() as i32
+            } else {
+                0
+            };
+
+            if results_count > 0 && !results_ptr.is_null() {
+                *results_ptr = val;
+            }
+            0
+        }
+    }) {
+        Ok(v) => v,
+        Err(_) => -1,
+    }
+}
+
+// ============================================================
+// FFI: Memory access
+// ============================================================
+
+#[no_mangle]
+pub extern "C" fn jerboa_sm_memory_read(
+    _handle: u64, _offset: u32, _buf: *mut u8, _len: u32,
+) -> i32 {
+    // TODO: access WebAssembly.Memory.buffer from the instance
+    set_last_error("sm memory_read: not yet implemented".into());
+    -1
+}
+
+#[no_mangle]
+pub extern "C" fn jerboa_sm_memory_write(
+    _handle: u64, _offset: u32, _buf: *const u8, _len: u32,
+) -> i32 {
+    set_last_error("sm memory_write: not yet implemented".into());
+    -1
+}
+
+#[no_mangle]
+pub extern "C" fn jerboa_sm_memory_size(_handle: u64) -> i64 {
+    -1
+}
+
+// ============================================================
+// FFI: Fuel / resource control
+// ============================================================
+
+#[no_mangle]
+pub extern "C" fn jerboa_sm_add_fuel(_handle: u64, _fuel: u64) -> i32 {
+    // SpiderMonkey uses interrupt callbacks for metering
+    0
+}
+
+#[no_mangle]
+pub extern "C" fn jerboa_sm_fuel_remaining(_handle: u64) -> i64 {
+    0
+}
+
+// ============================================================
+// FFI: Log buffer
+// ============================================================
+
+#[no_mangle]
+pub extern "C" fn jerboa_sm_get_log(
+    handle: u64, buf_ptr: *mut u8, buf_max: usize,
+) -> i64 {
+    match ::std::panic::catch_unwind(|| {
+        let instances = sm_instances().lock().unwrap();
+        let inst = match instances.get(&handle) {
+            Some(i) => i,
+            None => return -1i64,
+        };
+        let full = inst.host.log_buffer.join("\n");
+        let bytes = full.as_bytes();
+        if !buf_ptr.is_null() && buf_max > 0 {
+            let copy_len = bytes.len().min(buf_max);
+            unsafe {
+                ::std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf_ptr, copy_len);
+            }
+        }
+        bytes.len() as i64
+    }) {
+        Ok(v) => v,
+        Err(_) => -1,
+    }
+}