Why a JavaScript engine inside JavaScript

The browser already has a fast JavaScript engine, but it has no way to run a stranger's script with a fixed instruction budget, a fixed heap, no ambient DOM access, and a hard stop. Zipp in WebAssembly is that box: guest code runs on Zipp's interpreter inside linear memory, can reach nothing the host did not hand it, and is destroyed by terminating the Worker. That is the model Softn uses to run user JavaScript inside its own JavaScript host.

Build and size

The crate is built with wasm-bindgen 0.2.126 for the web target on Rust 1.92.0, with a 1 GiB link-time memory maximum and a 16 MiB stack, then stripped of name and producer sections and pre-compressed with Brotli at quality 11. There is deliberately no wasm-opt pass: measured, -Oz was 390 KB smaller raw but 22 KB larger on the wire and 2% slower.

Module sizes in the v0.0.21 release assets (Brotli-11 computed from each module)
VariantLanguagesRaw bytesBrotli-11 bytes
javascript (default)JavaScript5,743,0651,321,465
pythonJavaScript + Python, torch as a package7,460,5081,701,112
zipp_torch.wasmThe torch package for python2,054,964380,240
allJavaScript + Python with torch built in9,390,2192,056,613
QuickJS-NG v0.16.2 reactor, for scaleJavaScript1,528,293417,087

Zipp's JavaScript-only module is about 3.76× QuickJS-NG's raw and 3.17× on the wire. A page that never imports torch can serve python and skip the package; one that does fetches it on demand, and the pair costs slightly more than all because the package carries its own copy of the kernels' support code. A Python-only variant was tried and came out 85 bytes larger than the combined module, so it does not exist. The engine is compiled with safe-sandbox, meter-only, wasm-no-fs-loader and wasm-single-agent, and overflow-checks stay on in release.

The Engine API

engine.worker.js
import init, { Engine, zippProfile } from './zipp_wasm.js'
await init()
console.log(JSON.parse(zippProfile()).languages)  // ["javascript"] or ["javascript","python"]

const engine = new Engine()
engine.setInstructionBudget(200_000_000)
engine.initScript('const xs = [3,1,2]; console.log(xs.sort())')
console.log(engine.takeConsole())
console.log(engine.evalInContext('xs.length'))  // "3"
engine.dispose()

Beyond initScript, initSource(source, language) and initPythonProject(files, entry, argv), the class exposes callFunction, evalInContext and evalInContextRich, batched global get and set by stable slot index, a content fingerprint of the globals so a host cannot miss a mutation made through a reference, dispatchEvent for a deliberately limited event facade with no DOM, and resourceUsage for what the guest has consumed. Python builds add prewarmPython(), which compiles the Python runtime before the first run, and addPythonPackage(archive, kernels), through which zipp_torch.js registers torch once per module instance.

Host communication has exactly two channels. Synchronous __zippHostCall(kind, ...args) takes strings and returns one string, and every capability name (db.query, ls.getItem, nav.clipboardWrite and so on) is denied per engine until the host grants it. Asynchronous requests queue up for the host to drain with drainPendingHostCalls() and answer with resolveHostCallback(id, result). Installing a bridge object never grants authority by itself.

The Worker deadline model

Engine entry points are synchronous and there is no wall-time preemption inside the module. Run untrusted code in a dedicated Web Worker and terminate the Worker when its deadline expires. A timer inside the blocked Worker cannot fire, so the deadline must live in a different, responsive context and call Worker.terminate(). Destroying the Worker is the complete lifetime and memory reclamation boundary.

Any initialization failure, source-growth violation, or instruction, heap, output or dynamic-compilation limit violation disposes the engine, and stays terminal even if guest code catches the resulting error. Hosts read lastErrorKind() (guest, conversion, usage, source or resource) and never a message string.

Content-Security-Policy that admits the module
script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'

Resource limits

Limits are held to the artifact by a test that compares the module's reported profile with the documentation. All cumulative counters are lifetime limits for an engine, not per-entry.

Selected limits of the published module
ResourceLimit
VM instructions50,000,000 by default; host-sizable to 2,000,000,000 with setInstructionBudget
VM heap high-water mark536,870,912 bytes, payload-aware
WebAssembly linear memory1,073,741,824 bytes, fixed at link time
Initial guest source16,777,216 UTF-8 bytes
Runtime compilation (eval, Function)65,536 bytes per source, 16,777,216 retained, 16,384 attempts
Active JavaScript call frames4,096
One materialized string1,048,576 WTF-8 bytes
One regex pattern16,384 bytes, 32 nested groups, 64 alternatives
One BigInt1,048,576 bits
Lifetime console output8,388,608 bytes
Async host calls4,096 queued, 65,536 pending, 4,194,304 UTF-16 units per request

Several of these were raised after real programs hit them: the ArrayBuffer ceiling went from 1 MB to 32 MiB in v0.0.7 because an 8 MB Game Boy cartridge and a single 8.5-second frame of 48 kHz audio were both impossible, and renewInstructionBudget arrived in v0.0.8 because a 50-million-instruction lifetime budget was a fuse on every long-running embedder.

Performance in WebAssembly

The WebAssembly build is an interpreter, so the native benchmark tables do not apply to it. The only cross-engine WebAssembly comparison in the repository is explicitly marked not publishable: on the five rows that could be compared, QuickJS-NG led four. Interpreter cliffs found in September 2026 were closed with measured before-and-after numbers, for example a 64K non-ASCII charCodeAt loop from 4,462 ms to 3.2 ms and a join with 300K retained strings from 8,423 ms to 138 ms. Details are on the benchmarks page.

Getting the module

Each release ships zipp-wasm-<version>-web.zip (JavaScript) and zipp-wasm-<version>-web-python.zip (JavaScript and Python with torch built in); from v0.0.21 also zipp-wasm-<version>-web-python-base.zip (Python without torch) and zipp-wasm-<version>-web-torch.zip, the torch package and its loader. Each carries the wasm-bindgen glue, the module, TypeScript declarations, BUILD-INFO.txt, PROFILE.json and its own SHA256SUMS; serve the module Brotli-compressed, since the sizes above are what it costs on the wire. The glue and the .wasm are one artifact in two files, so serve them with matching cache keys; this site stamps both URLs with a content hash so a cached glue can never meet a new module. Pin zipp_torch.wasm by hash exactly as you pin the engine: a package built from another engine source is refused by its ABI check.