JavaScript
NPMzym-js runs the Zym VM in JavaScript and WebAssembly, in Node, in the browser, and in a Web Worker. It is an embedding API: you are the host, and the script is code you did not write.
print. Every embedder wires its own, so the
print used in the examples below is a native you register before a script can call it.
What zym-js Is
The JavaScript / WebAssembly binding for Zym. One ESM module, loaded once, from which you spin up VMs, compile and run scripts, register JS functions as natives, define globals, save and load bytecode, and call script functions from JS.
This page covers install, a first VM, the entry points, and how the module drops into a Node process,
a bundler, a plain module script, or a Worker.
VM & Natives covers the VM surface: compiling and running,
natives, value marshaling, errors.
Bounds & Control covers stopping a script that runs too long or
allocates too much, then inspecting and resuming it.
The C Embedding Guide is the native-side equivalent.
Install
npm install @zym-lang/zym-js
The package ships the ESM entry (js/zym.mjs), the TypeScript definitions
(js/zym.d.ts), and the wasm glue (dist/zym_js.mjs plus
dist/zym_js.wasm). No native build step runs at install time; the wasm is prebuilt.
0.3.0; the format changed since
0.2.0, so artifacts from that release do not load.
Quick Start
One import, one await, one VM, one free() when you are done.
import Zym from "@zym-lang/zym-js"; const vm = await Zym.newVM(); vm.registerNative("print(a)", (a) => { console.log(a.toJS()); return null; }); vm.run(` var message = "I like pie!"; print(message); `); vm.free();
That is the entire happy path. registerNative takes a signature string and any JS callable. The
signature is the name and parameter list the script will see. Arguments arrive as ZymValue
wrappers rather than bare JS values, and .toJS() decodes one into the natural JS shape. Both the
signature grammar and the wrapper are covered in full on VM & Natives.
Core Concepts
Five things are worth holding in mind before anything else.
-
One wasm module, many VMs. The wasm is loaded lazily on first use and shared across every
Zym.newVM()call. Each VM has its own heap, globals, and GC. - JS drives; Zym executes. You compile or run scripts from JS. Scripts can call back into JS through natives.
-
Values cross the boundary as wrappers. When a Zym value reaches JS, whether as a
native's argument or a script's return value, you receive a
ZymValue. Call.toJS()for a natural JS value, or pass the wrapper straight back to Zym. -
No manual rooting. The bridge anchors every live handle in a hidden VM map and releases
it via
FinalizationRegistrywhen the JS wrapper is collected. There is nopushRoot/popRootto get wrong. -
Execution is synchronous.
vm.run,vm.call, and native callbacks all block. Nothing else in JS gets a turn while a script runs, except a preemption handler. See Bounds & Control.
Entry Points
The module has two ways in, and the choice between them is about wasm instances rather than convenience. The default export shares one instance across everything you do; the named factory hands you a fresh one per call. Import is side-effect-free either way. Nothing is instantiated until you ask for a VM.
Zym — the default export, a lazy singleton
import Zym from "@zym-lang/zym-js"; const vm = await Zym.newVM(); // loads wasm on first call, cached afterwards const ver = await Zym.version(); // version string from the wasm await Zym.ready(); // optional: warm up the wasm ahead of time
This is the recommended entry point. The wasm is instantiated once behind the scenes and every
newVM() reuses the shared module.
| Member | Result | Notes |
|---|---|---|
Zym.newVM() | A VM | Loads the wasm on the first call and caches it; later calls reuse the module. Await it. |
Zym.version() | String | The version reported by the wasm itself. Await it. |
Zym.ready() | — | Optional. Warms the wasm up ahead of time so the first newVM() does not have to. |
ready() is the one place the laziness is visible. Without it, whichever code path calls
newVM() first pays for instantiating the module; awaiting Zym.ready() during
startup moves that work to a moment you picked.
createZym — the advanced factory
import { createZym } from "@zym-lang/zym-js"; const zym = await createZym({ locateFile: (f) => `/wasm/${f}` }); const vm = zym.newVM();
Each call to createZym loads a fresh module. Use it when you need an isolated wasm
instance (two concurrent VMs with separate memories, say), or when you want to pass custom
Emscripten options such as locateFile, wasmBinary, print, or
printErr. locateFile is the common reason in the browser: it tells the glue where
zym_js.wasm actually lives when your assets are not next to your scripts.
Note the asymmetry between the two snippets. Zym.newVM() is awaited because it may still have
to instantiate the shared module. zym.newVM() is not, because createZym already
resolved that instance and handed it to you.
Which one to import
| Import | Wasm instances | Reach for it when |
|---|---|---|
import Zym from "@zym-lang/zym-js" | One, shared, created on first use | Almost always. Any number of VMs, one module underneath, each VM with its own heap and GC. |
import { createZym } from "@zym-lang/zym-js" | One fresh instance per call | You want separate wasm heaps, or you need to pass Emscripten options. |
./js/zym.mjs by path and you get the same default Zym and the same
createZym. See Browser (no build) below.
Using It in a Project
The same module covers four host shapes. None of them changes the API; what changes is how the module and its wasm reach the runtime.
Node
Node 16 or newer, with ESM. In your package.json:
{ "type": "module" }
Then:
import Zym from "@zym-lang/zym-js"; const vm = await Zym.newVM(); vm.run(`/* ... */`); vm.free();
Dynamic import() works from CommonJS too:
const { default: Zym } = await import("@zym-lang/zym-js");
Browser (bundler)
Vite, Next.js, Rollup, Webpack 5, and esbuild all handle ESM plus wasm natively. Nothing special is required.
import Zym from "@zym-lang/zym-js"; export async function runUserScript(src) { const vm = await Zym.newVM(); vm.registerNative("print(a)", (a) => { console.log(a.toJS()); }); try { vm.run(src); } finally { vm.free(); } }
The try / finally is the shape to copy. A failing script throws out of
vm.run, and a VM that ran someone else's code should be released on that path as readily as on
the successful one.
Browser (no build)
Drop the two pieces, zym.mjs and the dist/ pair, into your static assets and
import by path from a module script:
<script type="module"> import Zym from "./js/zym.mjs"; const vm = await Zym.newVM(); vm.registerNative("print(a)", (a) => { console.log(a.toJS()); }); vm.run(`print("hi from the browser");`); vm.free(); </script>
The repository carries examples/browser-hello.html as a working single-page demo. If your
dist/ files do not sit where zym.mjs expects them, that is what
createZym's locateFile option is for.
Web Worker
The wasm module declares ENVIRONMENT=web,node,worker, so zym.mjs works unmodified
inside a Worker. This is the recommended shape for long-running scripts, and the reason is the
synchronous execution model: a script that takes a second on the main thread blocks the page for a second.
import Zym from "./js/zym.mjs"; self.onmessage = async (e) => { const vm = await Zym.newVM(); try { vm.run(e.data.source); self.postMessage({ ok: true }); } catch (err) { self.postMessage({ ok: false, error: err.message }); } finally { vm.free(); } };
const worker = new Worker("./worker.js", { type: "module" }); worker.postMessage({ source: `print("from worker");` });
A Worker moves the script off the main thread; it does not bound it. A runaway loop inside a Worker is still a runaway loop, and getting control back from one is the subject of Bounds & Control.
Bytecode
Compiling produces a chunk, and a chunk can be serialized to bytes and read back later, which is how you skip the compile on a subsequent load.
const chunk = vm.compile(src); const bytes = vm.serialize(chunk); // Uint8Array const reloaded = vm.loadBytecode(bytes); reloaded.run();
serialize hands back a Uint8Array, which is what you want for disk,
localStorage, IndexedDB, or a fetch response. loadBytecode accepts a
Uint8Array or an ArrayBuffer, so a fetched buffer needs no conversion.
Zym bytecode carries a magic header. Pre-1.0.0 the format version is held constant, and cross-version compatibility guarantees land with the 1.0 release. For now:
- Bytecode produced by a given
zym-jsbuild is loadable by the same build. - It is portable across builds of
0.3.0; the format changed since0.2.0, so artifacts from that release do not load. - Do not rely on bytecode portability across versions until 1.0.
compile, serialize, and loadBytecode are documented in full on
VM & Natives.
Building from Source
Needs Emscripten on your PATH (source emsdk_env.sh) plus CMake 3.20 or newer.
emcmake cmake -S . -B cmake-build-wasm cmake --build cmake-build-wasm --target zym_js
Output lands in dist/zym_js.mjs and dist/zym_js.wasm, which
js/zym.mjs imports.
Tuning the VM limits
The build sets the VM's limits above the core defaults, which are sized for microcontrollers: 4096 call frames, a 262144-slot value stack, and 32 preemption entries per VM. Each is a CMake cache variable, so a constrained target can dial them back without editing anything:
emcmake cmake -S . -B cmake-build-wasm -DZYM_FRAMES_MAX=256 -DZYM_PREEMPT_MAX_ENTRIES=8
Changing them needs a fresh configure; a plain rebuild reuses the cached definitions.
Running Tests
node test/node-smoke.mjs # JS-API smoke test (fast) node test/run-core-tests.mjs # regression suite over core_tests/*.zym, with a pass/fail summary node test/run-core-tests-raw.mjs # same suite, streaming each script's raw output only
Add --verbose to run-core-tests.mjs for full per-test output on failures.
FAQ & Gotchas
Do I have to call vm.free()?
No, but you should. A FinalizationRegistry will release a forgotten VM eventually, but the JS
GC makes no timing promises, and peak memory can grow before the cleanup fires.
Can I run two VMs at once?
Yes. Call Zym.newVM() as many times as you like; each returns an independent VM sharing the
same wasm module. If you need independent wasm heaps, call
createZym twice instead.
Can I call vm.run from inside a native?
Yes. The bridge is re-entrant and handles stay properly rooted across nested calls. What re-entering does change is what happens when a bound fires down there. See Bounds & Control.
JSON.stringify(someZymValue) hangs or crashes. Why?
Do not stringify a wrapper directly. It carries a back-reference to the wasm Module, whose HEAP
typed arrays are enormous. Call .toJS() first and stringify the decoded value.
String(wrapper) and template literals are fine, since they route through
display().
A native returns undefined. What does Zym see?
null. Any missing return is normalized.
Can a native return a function?
Not directly. Returning a JS function from a native is not auto-wrapped into a Zym closure. Register it as a
native up front with registerNative and expose it by name.
What happens if I register two natives with the same name and the same arity?
The second registration replaces the first, the same as the core C API. Overloading by arity is
supported: foo(a) and foo(a, b) can coexist, and Zym dispatches on the
argument count.
How do I hook print?
Zym ships no default print; every embedder wires its own. The simplest version is one variadic native:
vm.registerNative("print(...parts)", (...parts) => { console.log(parts.map((p) => p.toJS()).join(" ")); return null; });
For format-spec parity with the native print.c, test/run-core-tests.mjs in the
repository carries a reference implementation.