npm.io
0.1.0 • Published 3h ago

vasm-jsi

Licence
MIT
Version
0.1.0
Deps
0
Size
8.4 MB
Vulns
0
Weekly
0

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 install invokes it automatically to compile WAMR into WAMR.xcframework before 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 via FetchContent, and on iOS also clones the ios-cmake toolchain 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.

  • VasmModule

    • exports(): 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. imports must supply a function for every entry in imports() (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.
  • VasmInstance

    • call(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): Uint8Array
    • writeMemory(offset: number, bytes: Uint8Array): void
    • release(): void — frees the underlying native instance immediately.
  • Host functions (VasmHostFunction = (...args: number[]) => number | void) Called with plain numbers, on whatever thread the triggering call/ callAsync is on — for callAsync that'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 triggering call/callAsync rejects.

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) — compiles bytes, returning { module, status, error } where status is 'idle' | 'loading' | 'ready' | 'error'. bytes should be a stable reference (useMemo, module scope, etc.) — a fresh Uint8Array every render recompiles every render. Pass null/undefined when there's nothing to compile yet.
  • useVasmInstance(module, imports?) — instantiates a VasmModule (from useVasmModule or otherwise), returning { instance, status, error } where status is 'idle' | 'instantiating' | 'ready' | 'error'. imports is 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 per module. The set of imports (their moduleName/name keys) is expected to stay fixed across renders, since that's really a property of the compiled module; remount (e.g. via a key) 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

Keywords