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,308,147 | 1,239,957 |
all | JavaScript + Python | 7,773,932 | 1,777,382 |
| QuickJS-NG v0.16.2 reactor, for scale | JavaScript | 1,528,293 | 417,087 |
Zipp's module is about 3.6× QuickJS-NG's raw and 3.0× on the wire. A Python-only variant was tried and came out 85 bytes larger than all, 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.
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 and zipp-wasm-<version>-web-python.zip with the glue, the module and its pre-compressed .br body. 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.