vasm-jsi
A WebAssembly engine embedded directly in your Expo/React Native app.
vasm-jsi compiles and runs WASM modules in-process via JSI (iOS) and JNI
(Android), backed by WAMR's
interpreter (no AOT/JIT — this works under iOS's no-runtime-codegen
restriction). On web, it's a thin wrapper around the browser's own
WebAssembly object, so the same API works everywhere.
Only numeric value types (i32, i64, f32, f64) are supported — no
reference/vector types, no multi-value results yet.
Prerequisites
WAMR is compiled from source as part of your app's native build, the same way any Expo/React Native native module with its own C/C++ code is built — there's no separate manual build step. But it does mean your build environment needs:
- iOS: CMake on
PATH(brew install cmake), in addition to the Xcode command line tools you already need for any iOS build.pod installinvokes it automatically to compile WAMR intoWAMR.xcframeworkbefore Xcode builds anything. - Network access during the first native build, on both platforms: the
CMake configure step fetches WAMR's
simde(SIMD-emulation) dependency from GitHub viaFetchContent, and on iOS also clones theios-cmaketoolchain the same way. Neither is vendored, so a fully offline/sandboxed first build will fail; subsequent builds reuse what was already fetched. - Android otherwise needs nothing beyond the standard Android NDK, which the Android Gradle Plugin already provisions for any native module.
None of this applies on web — it's a plain wrapper around the browser's own
WebAssembly, no native toolchain involved.
Installation
From an Expo app:
npx expo install vasm-jsi
Expo Go doesn't include this module's native code, so you'll need a development build:
npx expo run:ios
npx expo run:android
Usage
import VasmJsi from 'vasm-jsi';
// 1. Compile and validate a WASM binary. Exports/imports are reflected
// eagerly, so module.exports()/module.imports() are cheap to call.
const module = await VasmJsi.compile(wasmBytes); // wasmBytes: Uint8Array
console.log(module.exports());
// [{ name: 'add', params: ['i32', 'i32'], results: ['i32'] }, ...]
console.log(module.imports());
// [{ moduleName: 'env', name: 'log', params: ['i32'], results: [] }, ...]
// 2. Instantiate it. Pass an imports object if the module declares any
// host-function imports (shaped like WebAssembly.Instance's own).
const instance = module.instantiate({
env: {
log: (value) => console.log('from wasm:', value),
},
});
// 3. Call an exported function.
const result = instance.call('add', [1, 2]);
console.log(result); // { hasResult: true, kind: 'i32', value: 3 }
// ...or off the JS thread, for anything that isn't a quick, small call:
const asyncResult = await instance.callAsync('add', [1, 2]);
// 4. Read/write the instance's linear memory.
const bytes = instance.readMemory(0, 16);
instance.writeMemory(0, new Uint8Array([1, 2, 3, 4]));
// 5. Release native resources immediately instead of waiting for GC.
instance.release();
module.release();
API
VasmJsi.compile(bytes: Uint8Array): Promise<VasmModule>Compiles and validates a WASM binary. Rejects if the bytes aren't a valid module.VasmModuleexports(): VasmExport[]— the module's callable exports.imports(): VasmImport[]— the module's declared host-function imports ({ moduleName, name, params, results }).instantiate(imports?: VasmImportObject): VasmInstance— allocates the instance's linear memory and execution environment.importsmust supply a function for every entry inimports()(extra keys are ignored); omit it for a module with no imports. Different instances of the same module may be given different bindings.release(): void— frees the underlying native module immediately.
VasmInstancecall(name: string, args: number[]): VasmCallResult— calls an exported function synchronously on the JS thread.callAsync(name: string, args: number[]): Promise<VasmCallResult>— same, but runs off the JS thread. Prefer this for anything beyond a quick, small call.readMemory(offset: number, length: number): Uint8ArraywriteMemory(offset: number, bytes: Uint8Array): voidrelease(): void— frees the underlying native instance immediately.
Host functions (
VasmHostFunction = (...args: number[]) => number | void) Called with plain numbers, on whatever thread the triggeringcall/callAsyncis on — forcallAsyncthat's off the JS thread, so a host function still safely touches JS state but isn't ordered relative to other JS execution the way a normal synchronous callback would be. A thrown error (or a non-numeric return where a number is expected) surfaces to the wasm caller as a trap, and the triggeringcall/callAsyncrejects.
Numeric conventions
All call arguments/results cross the JS boundary as plain numbers
regardless of the WASM value's actual type. For i64, this means values
beyond ±2^53 lose precision — there's no BigInt marshaling in this API yet.
React hooks
useVasmModule/useVasmInstance wrap the compile/instantiate lifecycle for
use in a component, handling loading/error state and releasing native
resources automatically on unmount or when their input changes:
import { useVasmModule, useVasmInstance } from 'vasm-jsi';
function Calculator({ wasmBytes }: { wasmBytes: Uint8Array }) {
const { module, status: moduleStatus, error: moduleError } = useVasmModule(wasmBytes);
const { instance, status: instanceStatus } = useVasmInstance(module, {
env: { log: (value) => console.log('from wasm:', value) },
});
if (moduleStatus === 'error') return <Text>Failed to compile: {moduleError!.message}</Text>;
if (instanceStatus !== 'ready') return <Text>Loading…</Text>;
const result = instance!.call('add', [1, 2]);
return <Text>1 + 2 = {result.value}</Text>;
}
useVasmModule(bytes: Uint8Array | null | undefined)— compilesbytes, returning{ module, status, error }wherestatusis'idle' | 'loading' | 'ready' | 'error'.bytesshould be a stable reference (useMemo, module scope, etc.) — a freshUint8Arrayevery render recompiles every render. Passnull/undefinedwhen there's nothing to compile yet.useVasmInstance(module, imports?)— instantiates aVasmModule(fromuseVasmModuleor otherwise), returning{ instance, status, error }wherestatusis'idle' | 'instantiating' | 'ready' | 'error'.importsis read fresh on every render without forcing a re-instantiate — host functions always call through to the latest closures, but the (comparatively expensive)instantiate()call only happens once permodule. The set of imports (theirmoduleName/namekeys) is expected to stay fixed across renders, since that's really a property of the compiled module; remount (e.g. via akey) if it needs to change.
Both hooks release their native resource (module/instance) automatically on
unmount and whenever their input identity changes, so you don't need to call
release() yourself when using them.
Development
This package is part of the vasm npm workspace; see the repository's
top-level CLAUDE.md for the full workspace layout, native build details,
and how the standalone C++ engine tests fit in. From this directory:
npm run build # tsc build of the TS API surface
npm run test # jest (jest-expo preset)
npm run lint # eslint src/
The engine core itself (ios/common/cpp/VasmEngine.{h,cpp}) has its own
platform-independent C++ test suite, runnable without Xcode/Gradle/Metro:
./scripts/test-engine.sh # host build (requires cmake, wabt)
./scripts/test-engine-android.sh # cross-compiled, run on a connected device/emulator