Skip to content

ZIPP v0.0.28

Latest

Choose a tag to compare

@github-actions github-actions released this 21 Oct 19:00
· 23 commits to main since this release

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.url is each module file's file: URL, as in Node
    (#76).
  • Unhandled promise rejections are reported
    (#77). zipp js and
    zipp mjs now end with zipp: Uncaught (in promise) <reason> and exit
    status 1, as Node does; an embedder hears of them through a sink or a
    browser-style unhandledrejection / rejectionhandled hook.
  • Error.prototype.stack holds V8-format frames with Node's line and
    column numbers (#78).
  • ScriptState::run_script runs a classic script, as a <script>
    element does: its let, const and class reach 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.
  • TextEncoder and TextDecoder, and subclassable WeakRef and
    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 a RangeError the script can catch, its work still charged to the
    step budget; ScriptState::set_regex_step_limit sets the ceiling, and
    ScriptState::set_sandbox_caps moves the hardened profile's other fixed
    caps (a regex's source size, join nesting, 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.
  • new on a Proxy around an arrow function ran the construct trap
    (#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,400 vars 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_limit collected 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_bytes is now the O(1) estimate the ceiling is checked against (was
    about 10 ms at 265 MB).
  • A top-level const destructuring threw "Assignment to constant
    variable"
    under script semantics
    (#75). Each leaf is now
    initialized once, by the extraction; $262.evalScript also returns the
    script's completion value.
  • import.meta.url was undefined
    (#76). It is the
    module file's URL, encoded as Node's pathToFileURL encodes it. Under
    set_confined_module_loader it 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.stack was 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. The zipp command names a script by its absolute path, a module
    by its import.meta.url, and an embedder names one with
    ScriptState::set_script_name; anything else is <anonymous>. Methods
    print without the receiver's type (m, not A.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.target is undefined in class field initializers and allowed in
    static ones.
  • A replaced generator @@iterator and a replaced collection size
    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) runs src as a classic script (ScriptEvaluation with
    GlobalDeclarationInstantiation, the $262.evalScript path) and returns
    its completion value or its throw. eval_in_context keeps
    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
    as hook("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...of helper steps a bound array iterator directly and
    rewrites its result object in place.
  • A switch over 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, and eval and new 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:

  1. sets zippEngineRelease in landing/package.json to 0.0.28;
  2. refreshes landing/public/playground-runtime and landing/public/wasm from
    zipp-0.0.28-javascript-python.wasm and zipp-0.0.28-javascript.wasm
    (node crates/zipp-wasm/embed-kit/package.mjs extract, then
    sync-playground.mjs --refresh-engine), SHA256SUMS-verified;
  3. syncs the sandbox playground (node landing/scripts/sync-sandbox.mjs with
    the release's files) and rewrites the site's Brotli copies
    (npm run compress:engines);
  4. updates the version, size and SHA-256 that landing/src/App.tsx and
    landing/README.md state about the bundled engine.

Full Changelog: v0.0.27...v0.0.28