The Zipp WebAssembly runtime
zipp-wasm is the engine built for wasm32-unknown-unknown: a persistent VM a browser host keeps alive across re-entries, with no JIT, no unsafe code, default-deny host capabilities and hard resource limits. It powers the playground on this site and the Softn sandbox.
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.
| Variant | Languages | Raw bytes | Brotli-11 bytes |
|---|---|---|---|
javascript (default) | JavaScript | 5,743,065 | 1,321,465 |
python | JavaScript + Python, torch as a package | 7,460,508 | 1,701,112 |
zipp_torch.wasm | The torch package for python | 2,054,964 | 380,240 |
all | JavaScript + Python with torch built in | 9,390,219 | 2,056,613 |
| QuickJS-NG v0.16.2 reactor, for scale | JavaScript | 1,528,293 | 417,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
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.
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.
| Resource | Limit |
|---|---|
| VM instructions | 50,000,000 by default; host-sizable to 2,000,000,000 with setInstructionBudget |
| VM heap high-water mark | 536,870,912 bytes, payload-aware |
| WebAssembly linear memory | 1,073,741,824 bytes, fixed at link time |
| Initial guest source | 16,777,216 UTF-8 bytes |
Runtime compilation (eval, Function) | 65,536 bytes per source, 16,777,216 retained, 16,384 attempts |
| Active JavaScript call frames | 4,096 |
| One materialized string | 1,048,576 WTF-8 bytes |
| One regex pattern | 16,384 bytes, 32 nested groups, 64 alternatives |
| One BigInt | 1,048,576 bits |
| Lifetime console output | 8,388,608 bytes |
| Async host calls | 4,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.