OpenWorkers runtime backend for the Nova JavaScript engine
- a pure-Rust, data-oriented JS/TS interpreter.
fetchandtaskhandlers run end-to-end againstopenworkers-corev0.14; no host I/O from JS yet. Engine study in NOTES-nova-api.md.
- Pure Rust, no C++ FFI surface - unlike V8/JSC, the whole engine is
auditable Rust. Boa already proved a pure-Rust engine slots into the
openworkers-core::Workertrait; Nova is the more ambitious take (data-oriented heap, ECS-style object storage). - Security narrative fit - a memory-safe engine aligns with the "trusted runtime for untrusted code" positioning.
- Crate:
nova_vm1.0.0 (March 2026), MPL-2.0. - Embedding entry point:
GcAgent::new(Default::default(), &DefaultHostHooks)(nova_vm::ecmascript::{DefaultHostHooks, GcAgent},nova_vm::engine::GcScope). - Known limitations (their README): performance "acceptable, but not fast", no sparse arrays, RegExp without lookaheads/lookbehinds/backreferences, no Promise subclassing, no WebAssembly.
- MPL-2.0 is file-level copyleft: fine as a near-unmodified dependency of an MIT project; contributions to the engine go upstream.
- No
temporal: the feature pullstemporal_rs0.1.2, which compiles only againsticu_calendar2.1, whilev8152 forces 2.2.1 throughtemporal_capi. A lockfile shared with the V8 backend can satisfy one or the other, never both, so this crate takes the nova_vm defaults minustemporal. Nothing here exposedTemporalto the guest. - The engine comes from a fork: 1.0.0 does not build without
temporal, becauseIntrinsics::temporal*read heap constants that only exist under the feature.openworkers/novais the 1.0.0 release plus the seven missing#[cfg(feature = "temporal")], taggedv1.0.0-ow.1. The dependency goes back to crates.io the day upstream takes them; the two commits are written to go there as they are.
Worker::new: builds aGcAgentwith per-worker host hooks, installs the platform layer (below) plus the native builtins, then evaluates the guest script.- Web platform: most of it is
openworkers-wintertc, the surface the V8 backend runs:Headers,Request,Response,URL,URLSearchParams,FormData,Blob,File, the streams,EventandEventTarget,AbortController,structuredClone,performance,navigator,atob,btoa,DOMException. A module is taken when this runtime answers every op it reads;PROVIDED_OPSnames five, so what is left out isTextEncoder/TextDecoder,URLPatternand the compression streams. The rest is here:TextEncoder/TextDecoder,cryptoand its digests, the timers,queueMicrotask,console, and the glue between the wire and aRequest.Headersiterates sorted, as the Fetch standard prescribes, but the response goes on the wire in the order the handler set it, which is what the V8, JSC and Boa backends send and what an HTTP header list is. - Handler shapes:
addEventListener('fetch'|'task')and the module conventionglobalThis.default = { fetch(request, env, ctx), task(...) }. A listener wins over the module export. Worker::execforEvent::Fetch: the request is marshaled to JS as JSON, dispatched to the registered handlers, and the response (status, headers, text body) is sent back throughFetchInit's response channel.Worker::execforEvent::Task: theTaskInitfields (taskId,payload,source,attempt, andscheduledTimefor a cron source) reach the guest as a task event, and the handler's result goes back throughTaskInit's result channel.- Async guest handlers (
async (event) => ...,respondWith(promise)) work as long as they need no host I/O: the embedder drains Nova's job queue until the dispatch promise settles. - Guest exceptions, missing handlers and syntax errors surface as
TerminationReason::Exceptionwith the JS error message. A task that throws is the exception: it becomes a failedTaskResult, since a queue wants the message recorded rather than the worker torn down. console.*prints to stderr for now.
addEventListener('fetch', (event) => {
event.respondWith(new Response('Hello, World!', { status: 200 }));
});
addEventListener('task', (event) => ({ doubled: event.payload.n * 2 }));Both handler styles work, and a task listener wins over the module export:
globalThis.default = {
async task(event, env, ctx) {
ctx.waitUntil(background());
return { doubled: event.payload.n * 2 };
},
};Nova evaluates classic scripts, so export default { ... } has to be
lowered to globalThis.default = { ... } first (the openworkers-transform
SWC pass does this; the embedder runs it, not this crate).
The result is whatever respondWith() was given, else the handler's return
value. A value carrying a boolean success is taken as a whole
TaskResult; anything else becomes its data. waitUntil promises are
awaited before the result is delivered, and a rejected one does not sink a
result already produced.
cargo run --release --example timings builds 200 workers and runs one task
on each. On an M-series laptop under load, for a one-line task handler:
| Step | median |
|---|---|
Worker::new (bootstrap + guest script eval) |
1.01 ms |
exec(Event::Task) on a warm worker |
0.04 ms |
Nearly all of Worker::new is the platform layer: the same measurement is
0.16 ms with a one-script bootstrap, and 1.45 ms before this crate's own URL
and base64 gave way to the surface's. Nova has no snapshot, so every worker
evaluates the layer from source; V8 pays for it once, at build time.
cargo run --release --example ssr -- <bundle.js> [expected.html] runs the
real workload: wake a worker, render a SvelteKit page, return the HTML. The
fixture is the 354 KB classic-script lowering of openworkers-website; every
route of that site is prerendered, so the bench asks for /ssr-bench, the
one path that reaches the renderer (a 404 page, 2615 bytes, through the full
SSR pipeline). Same laptop, under load, medians of 10 to 20 runs:
| Step | min | median |
|---|---|---|
| parse + compile 354 KB (never evaluated) | 3.98 ms | 4.13 ms |
Worker::new (parse + compile + top-level eval) |
5.4 ms | |
| first render | 5.2 ms | |
| warm render | 3.69 ms | 3.84 ms |
| cold cycle (new worker + one render) | 9.74 ms | 9.96 ms |
| RSS per resident worker | 6.0 MB |
Measured without the surface, which costs about a millisecond on a cold worker and nothing on a warm one.
The rendered bytes match the V8 reference render exactly, down to
SvelteKit's etag over the body. Parse and compile of a real-world bundle
costs about what V8 charges; guest compute is where the engine's own
"acceptable, but not fast" shows, at roughly 40x a V8 warm render.
openworkers-conformance scores this backend at 429 of 448, second behind
v8's 448 and well ahead of boa's 319. Headers, Request, Response, URL,
timers and the DOM event core are complete, and crypto is 29 of 30. Of the 19
that are left, 7 ask for WebAssembly, which the engine does not have; the rest
are the compression streams, URLPattern, Intl, ArrayBuffer.prototype.transfer,
an ECDSA pair, a windows-1252 decoder and fetch().
cargo run --release --example conformance replays the 17 requests of
openworkers-conformance/fixtures/sveltekit-app against the responses V8
recorded, on the same lowered bytes. Ten match byte for byte, status,
header order and body; the other seven die in cookies.set(), on a regex
the engine will not compile. Answer that one match call with null and
16 of 17 match, the last being the scenario where the oracle keeps a +
that WHATWG decodes to a space. The bindings the fixture wants are a JS
shim in the runner, since this backend does not expose Script.env yet.
NOTES-nova-api.md has the patterns and the diagnostics.
- No host I/O from JS: no
fetch(), and noOperationsHandlerwiring beyond console. A handler that never settles its response promise fails with an explicit error instead of hanging. RuntimeLimitsis half enforced:max_wall_clock_time_msbounds how long a drain waits on timers, so an interval nobody clears ends the request withWallClockTimeout. nova_vm 1.0 has no heap cap, instruction budget or interrupt API, so nothing bounds a guest that simply computes;abort()only rejects futureexec()calls, and a cap on jobs per drain is the other guard.- Unbounded guest recursion aborts the process: nova_vm 1.0 runs the
guest call stack on the host stack with no depth guard, so
(function f() { return f(); })()kills the runner. Script.envand bindings are not exposed to the guest yet (the guest-facing convention is still to be settled platform-wide).- Bodies cross the host boundary as UTF-8 text (request bodies lossy-decoded, response bodies and headers lose lone surrogates to U+FFFD). A response built on a stream is drained before it goes on the wire, so nothing streams out incrementally.
charCodeAtaborts the process on a heap-allocated string whose first character is a surrogate pair:'\u{1f600}\u{20ac}a'.charCodeAt(1)reads a mapping entry that nova_vm never filled in.codePointAtdoes the same one index further on. The string iterator is the way around it, and what this crate's own encoder uses.- The engine's RegExp is not safe for untrusted guests. Beyond the
patterns it refuses to compile (lookaround, backreferences, surrogate
escapes,
\0and\band unescaped[inside a character class - all thrown at first use, far from the literal), it gets non-ASCII input wrong:match.indexis a UTF-8 byte offset, so a regexreplaceorsplitwhose match is non-ASCII panics the process, and one whose match merely sits after non-ASCII text returns a silently wrong string.$1and$&are never substituted, themflag is ignored, and a regex literal is a shared singleton whoselastIndexcarries over from the previous request. In SvelteKit terms: pages render, butcookies.set(), the fatal-error fallback page and the CSP meta tag do not. See NOTES-nova-api.md for the reconnaissance and the upstream asks. crypto.subtlecovers the digests, HMAC and AES-GCM, and nothing else: no ECDSA, no RSA, no key format butraw. A key's material crosses to the host as hex for every operation, so it is in two places at once while a call is in flight. NoCompressionStreameither: it hands the host bytes, and bytes are what cannot cross this boundary.- The request the dispatch glue hands a handler is a string body wrapped in a stream: a body that is not UTF-8 text arrives lossy-decoded.
respondWithonly counts if it runs within one microtask turn of the handler returning; later calls lose the race with the dispatch glue. Same rule for a task, where losing the race means the handler's return value is delivered instead.- The glue is not isolated from the guest:
__ow_dispatch,__ow_headers_to_wireand the__ow_native_*builtins are ordinary globals, and replacing an intrinsic the glue uses (JSON.stringify, ...) breaks dispatch. A response can only reach the caller it belongs to, though:__ow_dispatchgets a dispatch id and the host drops any__ow_native_respondthat does not echo the one in flight. Atomics.waitAsyncthat nobody notifies ends the request withMaxIterationsReachedand leaks its parked waiter thread; nova offers no way to cancel it.
- Wire
OperationsHandlerfor guest-visiblefetch(): hand out a resolve/reject function pair per pending operation and interleave host futures with the job-queue drain (nova has no public promise constructor/inspection API - see NOTES-nova-api.md). - A
CryptoKey, and the sixsubtlemethods that need one to hold material. - The ops the surface still asks for.
textEncodeand the compression codecs want to hand back bytes, and nova_vm gives an embedder no way to build a typed array, so those wait on upstream;urlPatternParseonly needs theurlpatterncrate and a shape, but the pattern it produces is a RegExp, which this engine is not safe with. - Stream a response body out instead of draining it at the wire.
- Revisit heap limits upstream: the data-oriented heap should make per-worker memory accounting easier than FFI engines, but nova_vm 1.0 exposes no API for it yet.
- File the RegExp gaps and the
charCodeAtabort upstream (NOTES-nova-api.md): the first is what stands between this backend and an unmodified SvelteKit app, the second kills the process from guest code.