Repository navigation
ZIPP 0.0.28
A release for embedders and for large programs. Eleven issues reported by an
embedder are fixed, four of them with new embedding API: import.meta.url,
unhandled-rejection reporting, Error.prototype.stack in V8's format, and a
host entry point that runs a classic script. The interpreter is faster on
large bundled applications such as Babel, TypeScript and Prettier, and on
most benchmark programs.
Thank you to @takahashim, who found and
reported all eleven issues, each with a clear reproduction, most with a
pointer to the cause, and the four proposals with a working design.
import.meta.urlis each module file'sfile:URL, as in Node
(#76).- Unhandled promise rejections are reported
(#77).zipp jsand
zipp mjsnow end withzipp: Uncaught (in promise) <reason>and exit
status 1, as Node does; an embedder hears of them through a sink or a
browser-styleunhandledrejection/rejectionhandledhook. Error.prototype.stackholds V8-format frames with Node's line and
column numbers (#78).ScriptState::run_scriptruns a classic script, as a<script>
element does: itslet,constandclassreach later scripts
(#79).- Large applications run two to four times faster. Babel takes 5.2 s on
its workload (0.0.27: 11.3 s, or 13.8 s with its JIT), TypeScript 1.17 s
(2.86 s), Prettier 0.56 s (0.89 s; 2.20 s with the JIT). The 78 benchmark
programs take 0.91x 0.0.27's time interpreted and 0.96x with the JIT. TextEncoderandTextDecoder, and subclassableWeakRefand
FinalizationRegistry, so React's server renderer runs unchanged.
Downloads
| File | What it is |
|---|---|
zipp-0.0.28-windows-x86_64-portable.exe |
The Windows command line; run it from anywhere. |
zipp-0.0.28-windows-x86_64-installer.msi |
The same binary, installed for the current user into %LOCALAPPDATA%\Programs\ZIPP and added to PATH; no administrator rights. Uninstall from Settings > Apps. |
zipp-0.0.28-linux-x86_64 |
The Linux command line, x86-64 (glibc 2.35 or later). chmod +x it after downloading. |
zipp-0.0.28-linux-arm64 |
The same for 64-bit Arm. |
zipp-0.0.28-linux-armv7 |
The same for 32-bit Arm (hard float); interpreter only. |
zipp-0.0.28-javascript.wasm |
The JavaScript engine for browsers, Node, Deno and Bun. |
zipp-0.0.28-javascript-lite.wasm |
The JavaScript core without Intl and Temporal, built for size. |
zipp-0.0.28-javascript-python.wasm |
JavaScript and Python, torch built in. |
zipp-0.0.28-javascript-python-base.wasm |
JavaScript and Python without torch. |
zipp-0.0.28-torch-package.wasm |
The torch package for the -python-base engine. |
zipp-0.0.28-<engine>.wasm.br |
Each WebAssembly module compressed with Brotli (quality 11), about a quarter of its size. Serve it with Content-Encoding: br and Content-Type: application/wasm, or let zipp.mjs load it; the plain .wasm is the default. |
zipp-0.0.28-wasm-embed-kit.zip |
zipp.mjs (the loader and sandbox, version 1.2.0) and its types, the host SDK, the engine's TypeScript declarations, the browser GPU adapter, docs and three examples. |
SHA256SUMS |
Checksums of every file. |
A change in behaviour
zipp js and zipp mjs used to finish with status 0 when a promise was
rejected and nothing handled it. They now print
zipp: Uncaught (in promise) <reason> and exit with status 1 once the
event loop has no more work, as Node does. A rejection handled later in the
same turn, by a job already queued, is not reported. ZIPP_REPORT_UNHANDLED=1
restores the narrower report that Test262 runners rely on (only exceptions
that escape a job), with the old exit status.
Fixes
All eleven were reported by @takahashim.
- A regex that reached its step ceiling could not be caught once a host
set limits (#69). It
is now aRangeErrorthe script can catch, its work still charged to the
step budget;ScriptState::set_regex_step_limitsets the ceiling, and
ScriptState::set_sandbox_capsmoves the hardened profile's other fixed
caps (a regex's source size,joinnesting, JSON nesting, BigInt size)
per VM, with the old values as defaults. - A full heap was reported as a regex's memory budget
(#70). A search that
starts with the heap already at its ceiling now reports the heap's
exhaustion. newon a Proxy around an arrow function ran theconstructtrap
(#71). A Proxy whose
target cannot construct is now refused before its trap, as the
specification and Node do.- Compile time grew quadratically with a function's locals
(#72). Scopes are
indexed by name once they hold 16 names: 6,400vars compile in 8 ms
(was 352). With it, a long+chain compiles in linear time (25,600
terms: 16 ms, was 1.2 s), a function may declare more locals than the
register file holds, and a large module's captured bindings are shared, so
it compiles in linear time too. set_heap_limitcollected the whole heap on every call past half
the ceiling (#73). It
now collects only after a budget found the heap at its ceiling; 200
re-armed calls at 265 MB take 37 µs (were 3.0 s).resource_usage()walked the heap
(#74). Its
heap_bytesis now the O(1) estimate the ceiling is checked against (was
about 10 ms at 265 MB).- A top-level
constdestructuring threw "Assignment to constant
variable" under script semantics
(#75). Each leaf is now
initialized once, by the extraction;$262.evalScriptalso returns the
script's completion value. import.meta.urlwas undefined
(#76). It is the
module file's URL, encoded as Node'spathToFileURLencodes it. Under
set_confined_module_loaderit is the path below the loader's root
(file:///lib/dep.mjs), so untrusted code learns no host path.- Unhandled rejections were dropped silently
(#77): see Embedding
and the change in behaviour above. Error.prototype.stackwas empty
(#78). Every error
records its innermost ten frames; the getter formats them on the first
read and keeps the text, as V8 does. A frame's line and column are those
of the call it is suspended at, placed as V8 places them, and match
Node's. Thezippcommand names a script by its absolute path, a module
by itsimport.meta.url, and an embedder names one with
ScriptState::set_script_name; anything else is<anonymous>. Methods
print without the receiver's type (m, notA.m); a strict-mode tail
call has replaced its caller's frame; an error the engine raises itself
(null.x) gives its innermost frame no position; and with the JIT on, a
compiled function called without an interpreter frame can be missing from
the middle of a stack.- A host had no way to run a classic script
(#79):
ScriptState::run_script, below.
Also fixed:
v-flag class set expressions are computed as the specification says.- A class declaration is scoped to its block.
new.targetisundefinedin class field initializers and allowed in
static ones.- A replaced generator
@@iteratorand a replaced collectionsize
getter are honoured. - Iterator prototypes, RegExp and Proxy match the kangax compat-table's
details: ZIPP passes all of its ES5, ES6, ES2016+ and Intl tests. - Dependencies updated (Dependabot #63-#68).
Embedding
New on ScriptState:
run_script(src)runssrcas a classic script (ScriptEvaluation with
GlobalDeclarationInstantiation, the$262.evalScriptpath) and returns
its completion value or its throw.eval_in_contextkeeps
indirect-eval semantics.set_unhandled_rejection_sink(Box<dyn FnMut(&str)>)hears of each
rejection still unhandled at a microtask checkpoint, once per promise.set_promise_rejection_hook(expression), HTML's
HostPromiseRejectionTracker: the function the expression yields is called
ashook("unhandledrejection", promise, reason)and later
hook("rejectionhandled", promise, reason).set_script_name(name)names the compiled script in error stacks.set_regex_step_limit(steps),set_sandbox_caps(SandboxCaps)and
sandbox_caps()(#69).
zipp_vm::set_unhandled_rejections_fatal(on) makes a whole process end on
an unhandled rejection, as the zipp command does.
Performance
Measured on 22 October 2026, native Windows x86-64 (Ryzen 9 9950X3D): the
published 0.0.27 Windows binary against a plain release build of this
release's code (the published Windows binary is a plain release build too),
both with address-space randomization turned off so that image placement
does not move the numbers, medians of interleaved runs.
Bundled applications (each library bundled into one script that runs a
fixed workload, median of 5 runs; the time includes compiling the bundle):
| Program | 0.0.27 | 0.0.27, JIT | 0.0.28 | Bellard's QuickJS |
|---|---|---|---|---|
Babel: @babel/standalone transforms about 20 KB of modern JSX twice |
11.31 s | 13.75 s | 5.24 s | 3.78 s |
TypeScript: transpileModule on about 40 KB of classes, generics and enums |
2.86 s | 3.23 s | 1.17 s | 1.04 s |
| Prettier: formats JavaScript, TypeScript and Markdown | 0.89 s | 2.20 s | 0.56 s | 0.45 s |
| marked, lodash, acorn, terser, three.js, immer, RxJS and date-fns | 1.11 s | 1.32 s | 0.83 s | 1.84 s |
| Vue server rendering | 113 ms | 116 ms | 93 ms | 84 ms |
| Preact server rendering | 90 ms | 84 ms | 77 ms | 72 ms |
| React server rendering | fails (no TextEncoder) |
fails | 76 ms | fails |
A program of 256 or more functions now runs with its JIT off: on large
bundles the JIT spent more on compiling than its code saved, which is why
0.0.27 was slower with it than without. ZIPP_JIT_LARGE=1 keeps it on.
The 78 benchmark programs (median of 3 runs each; every output
identical): 0.911x 0.0.27's time interpreted (ZIPP_NOJIT=1), faster on 70
of them, and 0.957x with the JIT, faster on 57 and within 5% on the rest.
Interpreted, the other eight are within 3% except a generational-collector
row (survival, 1.07x) and the empty program, which starts in 7 ms
against 6. The largest interpreted gains are a large
heap's churn (0.64x), Math.imul (0.73x), method calls (0.73x), inherited
reads (0.74x) and own reads (0.76x); with the JIT, eval and
new Function code (0.45x) and the large heap (0.73x).
What changed, mostly in the interpreter:
- Method calls, construction, inherited reads and closures are cached by
the receiver's shape, and an inline-cache miss looks its key up in a
VM-wide (shape, key) table instead of the object's map. - Hot loops, module top levels and async bodies run in a smaller dispatch
loop; calls in it push their frame from a descriptor cached with the
callee. - A generator's steps run as frames of the loop that asks for them.
- Babel's loose
for...ofhelper steps a bound array iterator directly and
rewrites its result object in place. - A
switchover string literals is one table lookup; equal string
literals share one string; a large program's literals stay cached. - One compiler plans up to 65,536 object-literal sites, and objects of
more than twelve fields need no key list of their own. - A large heap's minor collections run longer between majors, and only a
large string split's or JSON parse's result is allocated old. - The JIT compiles functions that
throw, andevalandnew Function
code once it has run long enough to repay compiling it.
The README's performance figures still describe the profile-guided 0.0.26
measurements of 18 October 2026.
Publishing
As for earlier releases, the engines the site serves are refreshed AFTER this
release exists, in one commit that:
- sets
zippEngineReleaseinlanding/package.jsonto0.0.28; - refreshes
landing/public/playground-runtimeandlanding/public/wasmfrom
zipp-0.0.28-javascript-python.wasmandzipp-0.0.28-javascript.wasm
(node crates/zipp-wasm/embed-kit/package.mjs extract, then
sync-playground.mjs --refresh-engine), SHA256SUMS-verified; - syncs the sandbox playground (
node landing/scripts/sync-sandbox.mjswith
the release's files) and rewrites the site's Brotli copies
(npm run compress:engines); - updates the version, size and SHA-256 that
landing/src/App.tsxand
landing/README.mdstate about the bundled engine.
Full Changelog: v0.0.27...v0.0.28