JavaScript

NPM

zym-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.

Zym ships no default 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

Shell
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.

Version 0.3.0. The JS API, the native-signature grammar, and value marshaling are stable for this release. Bytecode is portable across builds of 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.

JavaScript
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.

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

JavaScript
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.

MemberResultNotes
Zym.newVM()A VMLoads the wasm on the first call and caches it; later calls reuse the module. Await it.
Zym.version()StringThe 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

JavaScript
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

ImportWasm instancesReach for it when
import Zym from "@zym-lang/zym-js"One, shared, created on first useAlmost 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 callYou want separate wasm heaps, or you need to pass Emscripten options.
Without a package manager the specifier changes but the exports do not. Import ./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:

package.json
{ "type": "module" }

Then:

JavaScript
import Zym from "@zym-lang/zym-js";
const vm = await Zym.newVM();
vm.run(`/* ... */`);
vm.free();

Dynamic import() works from CommonJS too:

JavaScript
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.

JavaScript
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:

HTML
<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.

JavaScript — worker.js
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();
    }
};
JavaScript — main.js
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.

JavaScript
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:

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.

Shell
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:

Shell
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

Shell
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:

JavaScript
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.