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, source build of 13 September 2026
VariantLanguagesRaw bytesBrotli-11 bytes
javascript (default)JavaScript5,308,1471,239,957
allJavaScript + Python7,773,9321,777,382
QuickJS-NG v0.16.2 reactor, for scaleJavaScript1,528,293417,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

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.

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