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. Since v0.0.22 each module also goes through Binaryen 125: constant propagation, redundant-set elimination and wasm-opt -O1 twice, then the functions are laid out by signature and body, which saves 9 to 12 KB of each module after Brotli. A full build keeps the result only when it is smaller raw and no larger after Brotli; -Oz was measured and rejected, smaller raw but larger on the wire and slower.
| Variant | Languages | Raw bytes | Brotli-11 bytes |
|---|---|---|---|
javascript (default) | JavaScript | 4,838,214 | 1,238,265 |
javascript-lite | JavaScript core without Intl and Temporal, built for size | 2,760,194 | 751,404 |
javascript-python-base | JavaScript + Python, torch as a package | 6,569,310 | 1,630,183 |
torch-package | The torch package for javascript-python-base | 2,163,109 | 403,018 |
javascript-python | JavaScript + Python with torch built in | 8,608,417 | 2,005,913 |
| QuickJS-NG v0.16.2 reactor, for scale | JavaScript | 1,528,293 | 417,087 |
Each file is zipp-<v>-<variant>.wasm and carries the JavaScript bindings generated for its build, the glue that releases up to v0.0.25 shipped as a separate file: they add 54,514 bytes raw and 12,585 after Brotli to the JavaScript engine, and 12,155 to 14,402 bytes after Brotli to each of the other three. Without them, measured the way earlier releases' modules were, the JavaScript engine is 4,783,700 bytes raw and 1,225,680 after Brotli, ZIPP Lite 2,705,686 and 739,249, Python without torch 6,507,324 and 1,615,781, and the complete engine 8,546,436 and 1,991,781: 0.2% to 0.4% more on the wire than v0.0.28's, and the torch package 325 bytes less. Zipp's JavaScript engine is about 3.17× QuickJS-NG's raw and 2.97× on the wire as published (3.13× and 2.94× without its bindings), and ZIPP Lite 1.81× and 1.80× (1.77× and 1.77×). A page that never imports torch can serve javascript-python-base and skip the package; one that does fetches it on demand, and the pair costs slightly more than javascript-python 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 { loadZipp } from './zipp.mjs'
const zipp = await loadZipp('./zipp-0.0.29-javascript.wasm')
console.log(zipp.languages) // ["javascript"] or ["javascript","python"]
const engine = new zipp.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 loadZipp({ engine, packages }) registers the torch package 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.
zipp.mjs, which imports the engine's bindings from a blob: URLscript-src 'self' blob: 'wasm-unsafe-eval'; worker-src 'self' blob: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. In October the size passes profiled the modules themselves: four workloads that were quadratic in a string's length became linear (a sticky-regex tokenizer over 190 KB from 17.8 s to 164 ms), Lite stopped making every register access a call, and the JavaScript module ran the size pass's corpus in 0.84 to 0.86 of v0.0.22's time in v0.0.23, level again in v0.0.24 while 4% smaller. Details are on the benchmarks page.
Getting the module
Each release ships every engine as one .wasm file that carries the JavaScript bindings generated for exactly that build in a custom section, beside a record of its version, commit and variant, so there is no separate glue file to keep in step with it. One loader, zipp.mjs, from the release's embed kit, reads that record and those bindings and runs any of the engines in a page, a Worker, Node, Deno or Bun.
| Asset | Contents |
|---|---|
zipp-<v>-javascript.wasm | JavaScript, with Intl and Temporal |
zipp-<v>-javascript-lite.wasm | ZIPP Lite: JavaScript without Intl and Temporal, built for size |
zipp-<v>-javascript-python.wasm | JavaScript and Python, torch built in |
zipp-<v>-javascript-python-base.wasm | JavaScript and Python without torch |
zipp-<v>-torch-package.wasm | torch, added to the -javascript-python-base engine when a program needs it |
zipp-<v>-<engine>.wasm.br | Each module above compressed with Brotli (from v0.0.26): the same engine at about a quarter of the size, for a server that sends it with Content-Encoding: br |
zipp-<v>-wasm-embed-kit.zip | The loader zipp.mjs, the one file a page needs beside an engine, with its TypeScript types, the engine API types, the host SDK, the GPU lab, the docs and examples |
Serve each engine Brotli-compressed, since the sizes above are what it costs on the wire, and check it against the release's SHA256SUMS. The release has each one ready as a .wasm.br (1,237,645 bytes for the JavaScript engine): send it with Content-Encoding: br and Content-Type: application/wasm and the browser decodes it. zipp.mjs 1.2.0, in the embed kit from v0.0.27 (and in v0.0.26's as republished), also loads a .wasm.br URL itself, decompressing it where the runtime can (Node, Firefox) or falling back to the plain .wasm beside it; the plain .wasm stays the default. Pin the torch package by hash exactly as you pin the engine: a package built from another engine source is refused by its ABI check, so take it from the same release as the engine. Up to v0.0.25, releases shipped zip archives instead, zipp-wasm-<version>-web.zip and its -web-lite, -web-python-base, -web-python and -web-torch siblings, each holding the wasm-bindgen glue and the module as two files to be served with matching cache keys, TypeScript declarations, BUILD-INFO.txt, PROFILE.json and its own SHA256SUMS.
The loader and its sandboxes
zipp.mjsimport { loadZipp } from "./zipp.mjs"
const zipp = await loadZipp("./zipp-0.0.29-javascript.wasm")
const sandbox = await zipp.createSandbox({
limits: { instructions: 50_000_000, timeoutMs: 5000 },
permissions: {
storage: { initial: { theme: "dark" } }, // a private, in-memory localStorage
files: { "notes.txt": "hello" }, // read with host.callSync("fs.read", ...)
network: ["https://api.example.com/"], // the only URLs its fetch() may reach
},
host: { "clock.now": () => Date.now() }, // answers host.call("clock.now", ...)
})
const { output } = await sandbox.run(source) // throws a ZippSandboxError on failure
await sandbox.close()A sandbox is default-deny. It starts with no storage, no files, no network and no host functions: without a grant, localStorage and the file calls throw an Error whose message begins SecurityError:, fetch does not exist, and host.call answers denied. Each permission opens one door: storage a private in-memory localStorage; files a map of paths to text, read-only unless writable; network a fetch that reaches only the listed origins or URL prefixes, with no cookies or credentials and redirects refused unless allowed; host the named functions. limits.instructions is a step budget the engine enforces, and limits.timeoutMs bounds each run or call: by default every sandbox runs in its own Worker, which is terminated when that time runs out. A failure is a ZippSandboxError whose category is guest, source, resource, timeout, host or usage, with whatever the program printed before it. The sandbox playground runs the 0.0.29 JavaScript engine this way and lets you switch each permission and limit and see the result.
loadZipp also takes { engine, packages }: given the -javascript-python-base engine and the torch package, it adds torch to the engine in the same step. What it resolves to also holds the engine's Engine class, for a host that drives the API above itself. The embed section of the home page shows the smallest default-deny example for each engine, links its two downloads, and offers a starter project with no build step.